abelien-harness 0.3.0 → 0.5.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 +3 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/skills/abelien-3dgen/SKILL.md +4 -2
- package/skills/abelien-3dgen/providers/tripo.json +64 -1
- package/skills/abelien-3dgen/references/engineering-lowpoly-qualification.md +20 -0
- package/skills/abelien-3dgen/references/host-api.md +128 -0
- package/skills/abelien-3dgen/references/provider-profile.md +4 -0
- package/skills/abelien-appearance/SKILL.md +2 -0
- package/skills/abelien-appearance/references/local-component-edit.md +31 -0
- package/skills/abelien-clarify/SKILL.md +8 -3
- package/skills/abelien-clarify/references/examples.md +9 -0
- package/skills/abelien-clarify/scripts/check_result.py +53 -1
- package/skills/abelien-clarify/scripts/test_check_result.py +40 -0
- package/skills/abelien-guide/SKILL.md +2 -0
- package/skills/abelien-guide/references/shared-response-rules.md +2 -2
- package/skills/abelien-hardware/SKILL.md +12 -2
- package/skills/abelien-hardware/domain.json +4 -4
- package/skills/abelien-hardware/references/search-index-format.md +41 -2
- package/skills/abelien-hardware/references/search-index-release.md +12 -1
- package/skills/abelien-hardware/references/selection-service-contract.md +3 -0
- package/skills/abelien-hardware/references/web-evidence-contract.md +2 -2
- package/skills/abelien-hardware/scripts/build-search-index.mjs +50 -6
- package/skills/abelien-hardware/scripts/dimensions.mjs +184 -0
- package/skills/abelien-hardware/scripts/electrical.mjs +125 -0
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +15 -0
- package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +10 -6
- package/skills/abelien-hardware/scripts/publish-search-index.mjs +2 -1
- package/skills/abelien-hardware/scripts/search-index-release.mjs +45 -19
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +40 -20
- package/skills/abelien-hardware/scripts/volc-web-search.mjs +6 -1
- package/skills/abelien-hardware/scripts/web-common.mjs +4 -1
- package/skills/abelien-hardware/scripts/web-provider-chain.mjs +8 -2
- package/skills/abelien-hardware/scripts/web-search-plan.mjs +16 -1
- package/skills/abelien-hardware/scripts/web-search.mjs +4 -2
- package/skills/abelien-hardware-selection/SKILL.md +1 -1
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +32 -2
- package/skills/abelien-hardware-selection/scripts/select.mjs +35 -7
- package/skills/abelien-harness/references/workflow-spec.md +15 -2
- package/skills/abelien-harness/scripts/generate3d-host.mjs +398 -0
- package/skills/abelien-harness/scripts/generate3d.mjs +54 -15
- package/skills/abelien-harness/scripts/receipt.mjs +8 -2
- package/skills/abelien-harness/scripts/studio.mjs +18 -10
- package/skills/abelien-structure/SKILL.md +3 -1
- package/skills/abelien-structure/references/build123d-execution.md +1 -1
- package/skills/abelien-structure/scripts/run_stage.py +8 -1
- package/skills/abelien-workspace/SKILL.md +2 -0
- package/skills/abelien-workspace/domain.json +1 -1
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
- package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/a6e8254e1839d94530ae243cba779e423953b7169cbce338f0503d748d44800b.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/b37f3b47e1e8bd1208859de57456152b149416e21949297b22bba937c51a5670.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/b780bf280d61596b43eb6dbc6a417cf8df1ec9a7ae79a6de10f048da9ed58729.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -3
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -3
- package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -3
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { assemblyOccupancy, dimensionFields, evaluateDimensions } from "./dimensions.mjs";
|
|
3
|
+
import { electricalFields, evaluateElectrical } from "./electrical.mjs";
|
|
4
|
+
import { selectionRequestDigest } from "./selection-request.mjs";
|
|
2
5
|
|
|
3
6
|
function error(message, details) {
|
|
4
7
|
const out = new Error(message);
|
|
@@ -23,6 +26,7 @@ export async function finalizeHardwareSelection({ input }) {
|
|
|
23
26
|
if (!recall || recall.status !== "completed" || !recall.source_snapshot) throw error("completed knowledge_recall is required");
|
|
24
27
|
if (!["proposed", "verified", "needs_confirmation"].includes(proposal.status)) throw error("selection_proposal.status is not recognized");
|
|
25
28
|
if (!["adapt_system", "assemble_new"].includes(proposal.plan_mode)) throw error("selection_proposal.plan_mode is not recognized");
|
|
29
|
+
if (recall.request_digest && recall.request_digest !== selectionRequestDigest(input)) throw error("recall does not match selection requirements and slots");
|
|
26
30
|
const proposedModels = requireArray(proposal.selected_models, "selected_models");
|
|
27
31
|
const coverageLedger = requireArray(proposal.coverage_ledger, "coverage_ledger");
|
|
28
32
|
const remainingSlots = requireArray(proposal.remaining_slots, "remaining_slots");
|
|
@@ -71,7 +75,14 @@ export async function finalizeHardwareSelection({ input }) {
|
|
|
71
75
|
slot_id: selected.slot_id, component_id: selected.component_id,
|
|
72
76
|
});
|
|
73
77
|
}
|
|
78
|
+
const dimensionCheck = evaluateDimensions(candidate, input.requirements);
|
|
79
|
+
if (dimensionCheck.status === "incompatible") throw error("selected component is dimensionally incompatible; choose a surviving candidate", { slot_id: selected.slot_id, component_id: selected.component_id, dimension_check: dimensionCheck });
|
|
74
80
|
return {
|
|
81
|
+
name: String(candidate.title ?? candidate.name ?? candidate.model ?? candidate.component_id),
|
|
82
|
+
// This is a deterministic selection-line identity, NOT an upstream catalog BOM identity.
|
|
83
|
+
bom_item_id: `selection-line-${digest({ slot_id: selected.slot_id, component_id: candidate.component_id }).slice(0, 24)}`,
|
|
84
|
+
...dimensionFields(candidate), dimension_check: dimensionCheck, ...electricalFields(candidate),
|
|
85
|
+
...(candidate.interfaces !== undefined ? { interfaces: candidate.interfaces } : {}),
|
|
75
86
|
slot_id: selected.slot_id, component_id: selected.component_id, quantity: selected.quantity,
|
|
76
87
|
selection_reason: selected.selection_reason, wiki_evidence: selected.wiki_evidence,
|
|
77
88
|
...(selected.constraint_checks !== undefined ? { constraint_checks: selected.constraint_checks } : {}),
|
|
@@ -84,6 +95,9 @@ export async function finalizeHardwareSelection({ input }) {
|
|
|
84
95
|
...(candidate.source_type === "external_web" ? { source_type: candidate.source_type, source: candidate.source, geometry: candidate.geometry, CAD_available: false, cad_available: false, engineering_verified: false } : {}),
|
|
85
96
|
};
|
|
86
97
|
});
|
|
98
|
+
// Trusted data proving a part cannot connect means one side must be replaced; a missing supply rail is a gap for select to report.
|
|
99
|
+
const failed = selectedModels.length > 1 ? evaluateElectrical(selectedModels).checks.filter(check => check.status === "fail") : [];
|
|
100
|
+
if (failed.length) throw error("selected components are electrically incompatible; replace one side of each failed check", { electrical_checks: failed });
|
|
87
101
|
const stable = {
|
|
88
102
|
requirements: input.requirements ?? {}, slots: input.slots ?? [], source_snapshot: recall.source_snapshot,
|
|
89
103
|
proposal: { ...proposal, selected_models: selectedModels },
|
|
@@ -97,6 +111,7 @@ export async function finalizeHardwareSelection({ input }) {
|
|
|
97
111
|
revision: `r-${hash.slice(0, 12)}`,
|
|
98
112
|
plan_mode: proposal.plan_mode,
|
|
99
113
|
selected_models: selectedModels,
|
|
114
|
+
...(input.requirements?.limits?.some(row => row?.envelope) ? { occupancy: assemblyOccupancy(selectedModels, input.requirements, input.policy) } : {}),
|
|
100
115
|
coverage_ledger: coverageLedger,
|
|
101
116
|
remaining_slots: remainingSlots,
|
|
102
117
|
unknowns,
|
|
@@ -2,7 +2,7 @@ import { digest, selectionRequestDigest } from "./selection-request.mjs";
|
|
|
2
2
|
import { failure, text } from "./web-common.mjs";
|
|
3
3
|
import { sha256, normalizePublicSourceUrl, pageText, publicAddress, publicReadFailureCodes } from "./public-page.mjs";
|
|
4
4
|
import { intactReceipt, successfulReceipt } from "./web-receipt.mjs";
|
|
5
|
-
import { volcQueryRequest } from "./volc-web-search.mjs";
|
|
5
|
+
import { volcQueryRequest, legacyVolcQueryRequest } from "./volc-web-search.mjs";
|
|
6
6
|
import { isIP } from "node:net";
|
|
7
7
|
export const chainProtocol = "prismer-volc-source-v2", chainPolicy = "prismer_then_volc";
|
|
8
8
|
export const receiptFields = ["raw", "raw_sha256", "response", "response_sha256", "request_id", "received_at", "http_status"];
|
|
@@ -13,8 +13,11 @@ export const same = (left, right) => digest({ value: left }) === digest({ value:
|
|
|
13
13
|
export const selectedReceipt = group => group?.attempts?.find(attempt => attempt.id === group.selected_attempt_id) ?? null;
|
|
14
14
|
export const queryResults = attempt => attempt?.provider === "volcengine" ? attempt.response?.Result?.WebResults?.map(result => ({ ...result, url: result?.Url ?? null })) : attempt?.response?.results;
|
|
15
15
|
export const missingKey = attempt => attempt?.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "missing_key" && attempt.error_code === "HARDWARE_WEB_CONFIG" && attempt.raw == null && attempt.response == null && attempt.http_status == null;
|
|
16
|
+
// A Prismer attempt skipped by the trusted host switch is recorded unsent with no receipt;
|
|
17
|
+
// it authorizes the same group's Volc query or direct source read.
|
|
18
|
+
export const prismerDisabled = attempt => attempt?.provider === "prismer" && attempt.status === "unavailable" && attempt.dispatch_state === "not_sent" && attempt.reason === "prismer_disabled" && attempt.error_code == null && attempt.raw == null && attempt.response == null && attempt.http_status == null;
|
|
16
19
|
export const knownDenial = attempt => attempt?.provider === "prismer" && attempt.status === "denied" && attempt.dispatch_state === "sent" && [401, 403, 402, 429].includes(attempt.http_status) && intactReceipt(attempt);
|
|
17
|
-
export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
|
|
20
|
+
export const permitsFallback = (attempt, query) => knownDenial(attempt) || missingKey(attempt) || prismerDisabled(attempt) || (query && attempt?.status === "completed" && successfulReceipt(attempt) && Array.isArray(queryResults(attempt)) && queryResults(attempt).length === 0);
|
|
18
21
|
export function expectedSelection(group, query) {
|
|
19
22
|
const primary = group.attempts?.[0], fallback = group.attempts?.[1];
|
|
20
23
|
if (!primary) return null;
|
|
@@ -67,13 +70,14 @@ function validateGroup(group, item, page, partial) {
|
|
|
67
70
|
const operation = page ? sourceOperation(page.source_url) : null;
|
|
68
71
|
for (const [index, attempt] of attempts.entries()) {
|
|
69
72
|
const provider = index === 0 ? "prismer" : query ? "volcengine" : "direct_https", kind = query ? "query" : index === 0 ? operation.kind : "direct_page";
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
// Volc receipts saved before the Count-5 default keep their frozen Count-10 request shape.
|
|
74
|
+
const requests = query ? index === 0 ? [primaryQuery(item)] : [volcQueryRequest(item), legacyVolcQueryRequest(item)] : [index === 0 ? operation.request : { url: page.source_url, method: "GET" }];
|
|
75
|
+
if (attempt.provider !== provider || attempt.kind !== kind || attempt.role !== (index === 0 ? "primary" : "fallback") || attempt.group_id !== group.id || !requests.some(request => attempt.id === operationId(group.id, provider, kind, request) && same(attempt.request, request))) return false;
|
|
72
76
|
if (index === 1 && !permitsFallback(attempts[0], query)) return false;
|
|
73
77
|
if (["in_progress", "unknown", "failed"].includes(attempt.status)) return false;
|
|
74
78
|
if (provider === "direct_https") { if (!validDirect(attempt) || (attempt.dispatch_state === "not_sent" && operation.kind !== "parse") || (attempt.public_read_failure && operation.kind === "parse")) return false; continue; }
|
|
75
79
|
if (Object.hasOwn(attempt, "public_read_failure")) return false;
|
|
76
|
-
if (missingKey(attempt) || knownDenial(attempt)) continue;
|
|
80
|
+
if (missingKey(attempt) || knownDenial(attempt) || prismerDisabled(attempt)) continue;
|
|
77
81
|
if (attempt.dispatch_state !== "sent" || !["completed", "pending"].includes(attempt.status) || !successfulReceipt(attempt)) return false;
|
|
78
82
|
if (query && !Array.isArray(queryResults(attempt))) return false;
|
|
79
83
|
if (attempt.status === "pending" && !(kind === "parse" && text(attempt.task_id) && attempt.task_id === attempt.response.taskId)) return false;
|
|
@@ -81,7 +85,7 @@ function validateGroup(group, item, page, partial) {
|
|
|
81
85
|
if (page && (!Array.isArray(attempt.observations) || !attempt.observations.every(value => ["status", "result"].includes(value.kind) && (value.error_code ? ((value.raw === undefined && value.response === undefined) || intactReceipt(value)) : successfulReceipt(value) && value.response.taskId === attempt.task_id)))) return false;
|
|
82
86
|
}
|
|
83
87
|
const expected = expectedSelection(group, query), selected = selectedReceipt(group);
|
|
84
|
-
if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
|
|
88
|
+
if (group.selected_attempt_id == null) return partial && group.status === "in_progress" && (knownDenial(attempts[0]) || missingKey(attempts[0]) || prismerDisabled(attempts[0]) || successfulReceipt(attempts[0])) && (!page || page.attempt == null);
|
|
85
89
|
if (!selected || !expected || selected.id !== expected.id) return false;
|
|
86
90
|
return group.selected_provider === selected.provider && (query ? group.status === "completed" && receiptFields.every(key => same(group[key], selected[key])) : same(page.attempt, selected));
|
|
87
91
|
}
|
|
@@ -8,7 +8,7 @@ import { publishHardwareSearchIndex } from "./search-index-release.mjs";
|
|
|
8
8
|
function parseArgs(argv) {
|
|
9
9
|
const flags = {};
|
|
10
10
|
const booleans = new Set(["--dry-run", "--skip-smoke", "--bootstrap-manifest"]);
|
|
11
|
-
const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency"]);
|
|
11
|
+
const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency", "--component-snapshot"]);
|
|
12
12
|
for (let i = 0; i < argv.length; i++) {
|
|
13
13
|
const name = argv[i];
|
|
14
14
|
if (booleans.has(name)) flags[name.slice(2)] = true;
|
|
@@ -30,6 +30,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
30
30
|
sourceDir: path.resolve(cwd, flags.source),
|
|
31
31
|
outputDir: flags.output ? path.resolve(cwd, flags.output) : undefined,
|
|
32
32
|
releaseId: flags["release-id"],
|
|
33
|
+
componentSnapshot: flags["component-snapshot"] ? path.resolve(cwd, flags["component-snapshot"]) : undefined,
|
|
33
34
|
env,
|
|
34
35
|
fetchImpl: deps.fetchImpl || fetch,
|
|
35
36
|
dryRun: Boolean(flags["dry-run"]),
|
|
@@ -21,6 +21,12 @@ function parseJson(bytes, code, key) {
|
|
|
21
21
|
catch { throw releaseError(code, `invalid JSON object: ${key}`, { key }); }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// 回滚点只作记录:旧 catalog 坏了也不阻断本次激活,release_id 记 null。
|
|
25
|
+
function previousReleaseId(object) {
|
|
26
|
+
try { return JSON.parse(Buffer.from(object.bytes).toString("utf8")).release_id ?? null; }
|
|
27
|
+
catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
function safeFile(root, relative) {
|
|
25
31
|
const base = path.resolve(root);
|
|
26
32
|
const file = path.resolve(base, relative);
|
|
@@ -69,6 +75,7 @@ export function validateLocalRelease(outputDir) {
|
|
|
69
75
|
if (index.source_manifest_sha256 !== catalog.source_manifest?.sha256) {
|
|
70
76
|
throw releaseError("INDEX_RELEASE_INVALID", `index Manifest digest mismatch: ${descriptor.index_key}`);
|
|
71
77
|
}
|
|
78
|
+
if (index.records.some(record => !/^[a-f0-9]{64}$/.test(record.sha256 ?? ""))) throw releaseError("INDEX_RELEASE_INVALID", "every Wiki needs a content SHA-256; rebuild legacy indexes");
|
|
72
79
|
documents += index.records.length;
|
|
73
80
|
files.push({ key: descriptor.index_key, file, bytes, sha256: descriptor.sha256 });
|
|
74
81
|
}
|
|
@@ -132,10 +139,7 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
|
|
|
132
139
|
if (actual !== entry.sha256) throw releaseError("REMOTE_WIKI_MISMATCH", "remote Wiki SHA-256 does not match Manifest", { key, expected_sha256: entry.sha256, actual_sha256: actual });
|
|
133
140
|
});
|
|
134
141
|
const key = `${config.prefix}${catalog.source_manifest.object_key || "_manifest.json"}`;
|
|
135
|
-
await
|
|
136
|
-
fetchImpl,
|
|
137
|
-
headers: { "content-type": "application/json", "if-none-match": "*", "x-amz-meta-sha256": sha256(manifestBytes) },
|
|
138
|
-
});
|
|
142
|
+
await putImmutable(config, key, manifestBytes, fetchImpl);
|
|
139
143
|
const uploaded = await getTosObject(config, key, { fetchImpl });
|
|
140
144
|
if (sha256(uploaded.bytes) !== catalog.source_manifest.sha256) {
|
|
141
145
|
throw releaseError("SOURCE_MANIFEST_MISMATCH", "uploaded Wiki Manifest SHA-256 mismatch", { key });
|
|
@@ -143,6 +147,15 @@ async function bootstrapRemoteManifest({ config, catalog, sourceDir, fetchImpl,
|
|
|
143
147
|
return { key, etag: uploaded.etag, sha256: catalog.source_manifest.sha256, bootstrapped: true, verified_wiki_objects: manifest.length };
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
function objectContentType(key) {
|
|
151
|
+
if (key.endsWith(".gz")) return "application/gzip";
|
|
152
|
+
if (key.endsWith(".md")) return "text/markdown; charset=utf-8";
|
|
153
|
+
return "application/json";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 阿里云 OSS 的 S3 兼容写入没有条件 PUT:If-None-Match / If-Match 一律 400 NotImplemented,
|
|
157
|
+
// x-oss-forbid-overwrite 被忽略照常覆盖。不可变对象因此靠"先读后写 + 回读 SHA":键按内容寻址,
|
|
158
|
+
// 并发写同一键只会写入同一份字节;已存在但字节不同即冲突失败,不覆盖。
|
|
146
159
|
async function putImmutable(config, key, bytes, fetchImpl) {
|
|
147
160
|
const expected = sha256(bytes);
|
|
148
161
|
const existing = await getTosObject(config, key, { fetchImpl, optional: true });
|
|
@@ -151,14 +164,10 @@ async function putImmutable(config, key, bytes, fetchImpl) {
|
|
|
151
164
|
if (actual !== expected) throw releaseError("IMMUTABLE_OBJECT_CONFLICT", "release object already exists with different bytes", { key, expected_sha256: expected, actual_sha256: actual });
|
|
152
165
|
return { key, sha256: expected, reused: true };
|
|
153
166
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
159
|
-
} catch (cause) {
|
|
160
|
-
if (cause.code !== "TOS_HTTP_STATUS" || cause.details?.status !== 412) throw cause;
|
|
161
|
-
}
|
|
167
|
+
await putTosObject(config, key, bytes, {
|
|
168
|
+
fetchImpl,
|
|
169
|
+
headers: { "content-type": objectContentType(key), "x-amz-meta-sha256": expected },
|
|
170
|
+
});
|
|
162
171
|
const uploaded = await getTosObject(config, key, { fetchImpl });
|
|
163
172
|
const actual = sha256(uploaded.bytes);
|
|
164
173
|
if (actual !== expected) throw releaseError("INDEX_UPLOAD_VERIFY_FAILED", "uploaded release object SHA-256 mismatch", { key, expected_sha256: expected, actual_sha256: actual });
|
|
@@ -242,12 +251,12 @@ export async function smokePublishedIndex({ env, fetchImpl = fetch, categoryPath
|
|
|
242
251
|
};
|
|
243
252
|
}
|
|
244
253
|
|
|
245
|
-
export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
|
|
254
|
+
export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, componentSnapshot, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
|
|
246
255
|
if (!sourceDir) throw releaseError("USAGE", "sourceDir is required");
|
|
247
256
|
const temporary = !outputDir;
|
|
248
257
|
const output = outputDir ? path.resolve(outputDir) : fs.mkdtempSync(path.join(os.tmpdir(), "abelien-hardware-index-"));
|
|
249
258
|
try {
|
|
250
|
-
const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId });
|
|
259
|
+
const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId, componentSnapshot });
|
|
251
260
|
const local = validateLocalRelease(output);
|
|
252
261
|
if (dryRun) return {
|
|
253
262
|
status: "validated",
|
|
@@ -262,6 +271,18 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
|
|
|
262
271
|
};
|
|
263
272
|
|
|
264
273
|
const config = createTosConfig(env);
|
|
274
|
+
if (built.generated_source) {
|
|
275
|
+
const manifestBytes = fs.readFileSync(path.join(built.generated_source, "_manifest.json"));
|
|
276
|
+
const entries = JSON.parse(manifestBytes);
|
|
277
|
+
const limit = Number(concurrency || env?.TOS_PUBLISH_CONCURRENCY || 6);
|
|
278
|
+
await mapConcurrent(entries, Number.isFinite(limit) ? limit : 6, async entry => {
|
|
279
|
+
const bytes = fs.readFileSync(safeFile(built.generated_source, entry.path));
|
|
280
|
+
if (sha256(bytes) !== entry.sha256) throw releaseError("SOURCE_WIKI_MISMATCH", "generated Wiki changed after build");
|
|
281
|
+
await putImmutable(config, `${config.prefix}${entry.path}`, bytes, fetchImpl);
|
|
282
|
+
});
|
|
283
|
+
await putImmutable(config, `${config.prefix}${local.catalog.component_snapshot.object_key}`, fs.readFileSync(path.join(built.generated_source, "_component-dimensions.json")), fetchImpl);
|
|
284
|
+
await putImmutable(config, `${config.prefix}${local.catalog.source_manifest.object_key}`, manifestBytes, fetchImpl);
|
|
285
|
+
}
|
|
265
286
|
let manifest = await requireMatchingManifest(config, local.catalog, fetchImpl, { optional: true });
|
|
266
287
|
if (!manifest && !bootstrapManifest) {
|
|
267
288
|
throw releaseError("SOURCE_MANIFEST_MISSING", "TOS Wiki Manifest is missing; rerun with --bootstrap-manifest to verify every remote Wiki object before creating it", {
|
|
@@ -277,11 +298,15 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
|
|
|
277
298
|
const uploaded = await mapConcurrent(local.files, Number.isFinite(limit) ? limit : 6, item =>
|
|
278
299
|
putImmutable(config, `${config.prefix}${item.key}`, item.bytes, fetchImpl));
|
|
279
300
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
};
|
|
284
|
-
|
|
301
|
+
// OSS 没有条件 PUT(见 putImmutable),激活前按内容 SHA 重读比对;OSS 对 JSON 对象不回 ETag,
|
|
302
|
+
// 故不能比 ETag。重读到写入之间仍有窗口,写后 verify 的 release_id 比对兜住输掉的竞争。
|
|
303
|
+
const previousSha256 = previousCatalog ? sha256(previousCatalog.bytes) : null;
|
|
304
|
+
const currentCatalog = await getTosObject(config, catalogKey, { fetchImpl, optional: true });
|
|
305
|
+
const currentSha256 = currentCatalog ? sha256(currentCatalog.bytes) : null;
|
|
306
|
+
if (currentSha256 !== previousSha256) {
|
|
307
|
+
throw releaseError("CATALOG_ACTIVATION_RACE", "active catalog changed while this release was uploading", { expected_sha256: previousSha256, actual_sha256: currentSha256 });
|
|
308
|
+
}
|
|
309
|
+
await putTosObject(config, catalogKey, local.catalogBytes, { fetchImpl, headers: { "content-type": "application/json" } });
|
|
285
310
|
const verified = await verifyPublishedIndex({ env, fetchImpl, concurrency: limit });
|
|
286
311
|
if (verified.release_id !== local.catalog.release_id) {
|
|
287
312
|
throw releaseError("CATALOG_ACTIVATION_RACE", "active catalog is not the release just published", { expected_release_id: local.catalog.release_id, actual_release_id: verified.release_id });
|
|
@@ -295,6 +320,7 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
|
|
|
295
320
|
uploaded_shards: uploaded.filter(item => !item.reused).length,
|
|
296
321
|
reused_shards: uploaded.filter(item => item.reused).length,
|
|
297
322
|
catalog_key: catalogKey,
|
|
323
|
+
previous_catalog: previousCatalog ? { sha256: previousSha256, release_id: previousReleaseId(previousCatalog) } : null,
|
|
298
324
|
verified,
|
|
299
325
|
smoke: smokeResult,
|
|
300
326
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import zlib from "node:zlib";
|
|
2
|
+
import { dimensionFields, evaluateDimensions, orderByDimensionTier, sameDimensionMetadata, screenDimensions, shortlistByDimensionTier, validateRequirementEnvelopes } from "./dimensions.mjs";
|
|
3
|
+
import { electricalFields } from "./electrical.mjs";
|
|
2
4
|
import { selectionRequestDigest, validCategoryPath } from "./selection-request.mjs";
|
|
3
5
|
import { createTosConfig, getTosObject, sha256 } from "./tos-object-store.mjs";
|
|
4
6
|
|
|
@@ -81,11 +83,17 @@ export function parseHardwareWiki(content) {
|
|
|
81
83
|
"schema", "type", "title", "description", "manufacturer", "model", "aliases", "resource_urn", "record_id",
|
|
82
84
|
"record_version", "status", "category_path", "category_leaf", "primary_function", "integration_level",
|
|
83
85
|
"hardware_form", "component_kind", "functional_capabilities", "capability_coverage", "source_url", "source_updated_at",
|
|
86
|
+
"dimensions_mm", "dimension_basis", "dimension_geometry", "dimension_source", "interfaces", "electrical", "electrical_source",
|
|
84
87
|
];
|
|
85
88
|
const meta = {};
|
|
86
89
|
for (const key of wanted) {
|
|
87
90
|
const line = frontMatter.match(new RegExp(`^${key}:\\s*(.*)$`, "m"));
|
|
88
|
-
if (line)
|
|
91
|
+
if (line) {
|
|
92
|
+
if (["dimension_source", "dimensions_mm", "electrical", "electrical_source"].includes(key)) {
|
|
93
|
+
try { meta[key] = JSON.parse(line[1]); }
|
|
94
|
+
catch { throw error("WIKI_INVALID", `${key} must be generated inline JSON`); }
|
|
95
|
+
} else meta[key] = scalar(line[1]);
|
|
96
|
+
}
|
|
89
97
|
}
|
|
90
98
|
meta.selection_profile = {
|
|
91
99
|
suitable_for: nestedInline(frontMatter, "selection_profile", "suitable_for") ?? [],
|
|
@@ -199,6 +207,8 @@ function parseCategoryIndex(object, descriptor, key) {
|
|
|
199
207
|
}
|
|
200
208
|
if (recordIds.has(record.record_id)) throw error("WIKI_SEARCH_INDEX_INVALID", "category index contains duplicate record_id", { key, record_id: record.record_id });
|
|
201
209
|
recordIds.add(record.record_id);
|
|
210
|
+
if (!/^[a-f0-9]{64}$/.test(record.sha256 ?? "")) throw error("WIKI_SEARCH_INDEX_INVALID", "category index requires every Wiki content SHA-256; rebuild legacy indexes", { key, record_id: record.record_id });
|
|
211
|
+
dimensionFields(record);
|
|
202
212
|
record.path = safeRelativeObjectKey(record.path, "Wiki path");
|
|
203
213
|
}
|
|
204
214
|
return index;
|
|
@@ -218,6 +228,7 @@ async function mapConcurrent(items, concurrency, mapper) {
|
|
|
218
228
|
}
|
|
219
229
|
|
|
220
230
|
function validateInput(input) {
|
|
231
|
+
validateRequirementEnvelopes(input?.requirements);
|
|
221
232
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw error("VALIDATION", "hardware search input must be an object");
|
|
222
233
|
if (!Array.isArray(input.slots) || input.slots.length === 0) throw error("VALIDATION", "hardware search requires non-empty slots");
|
|
223
234
|
for (const slot of input.slots) {
|
|
@@ -258,6 +269,10 @@ function candidateFromDocument(document, config, ranking, includeContent, indexS
|
|
|
258
269
|
manufacturer: meta.manufacturer == null ? "Unspecified" : String(meta.manufacturer),
|
|
259
270
|
model: meta.model == null ? meta.model : String(meta.model),
|
|
260
271
|
title: meta.title,
|
|
272
|
+
name: meta.title,
|
|
273
|
+
...dimensionFields(meta),
|
|
274
|
+
...electricalFields(meta),
|
|
275
|
+
...(meta.interfaces !== undefined ? { interfaces: meta.interfaces } : {}),
|
|
261
276
|
category_path: meta.category_path,
|
|
262
277
|
integration_level: meta.integration_level,
|
|
263
278
|
hardware_form: meta.hardware_form,
|
|
@@ -279,7 +294,7 @@ function candidateFromDocument(document, config, ranking, includeContent, indexS
|
|
|
279
294
|
};
|
|
280
295
|
}
|
|
281
296
|
|
|
282
|
-
function createWikiLoader(config, fetchImpl) {
|
|
297
|
+
function createWikiLoader(config, fetchImpl, { legacy = false } = {}) {
|
|
283
298
|
const cache = new Map();
|
|
284
299
|
return entry => {
|
|
285
300
|
if (!cache.has(entry.path)) {
|
|
@@ -290,9 +305,10 @@ function createWikiLoader(config, fetchImpl) {
|
|
|
290
305
|
path: entry.path, indexed_record_id: entry.record_id, wiki_record_id: parsed.meta.record_id,
|
|
291
306
|
});
|
|
292
307
|
}
|
|
293
|
-
if (entry.sha256
|
|
308
|
+
if (!/^[a-f0-9]{64}$/.test(entry.sha256 ?? "") || sha256(parsed.content) !== entry.sha256) {
|
|
294
309
|
throw error("WIKI_SEARCH_INDEX_MISMATCH", "Wiki content SHA-256 does not match its search index", { path: entry.path });
|
|
295
310
|
}
|
|
311
|
+
if (!legacy && !sameDimensionMetadata(parsed.meta, entry)) throw error("WIKI_SEARCH_INDEX_MISMATCH", "Wiki dimension metadata does not match its search index", { path: entry.path });
|
|
296
312
|
return { ...parsed, etag: object.etag, lastModified: object.lastModified, entry };
|
|
297
313
|
}));
|
|
298
314
|
}
|
|
@@ -300,7 +316,7 @@ function createWikiLoader(config, fetchImpl) {
|
|
|
300
316
|
};
|
|
301
317
|
}
|
|
302
318
|
|
|
303
|
-
async function searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, includeContent }) {
|
|
319
|
+
async function searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, maxFetch, includeContent }) {
|
|
304
320
|
const catalogKey = `${config.prefix}_catalog.json`;
|
|
305
321
|
const { categories } = parseCatalog(catalogObject, catalogKey);
|
|
306
322
|
const indexCache = new Map();
|
|
@@ -314,7 +330,7 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
|
|
|
314
330
|
if (!indexCache.has(descriptor.index_key)) {
|
|
315
331
|
indexCache.set(descriptor.index_key, getObject(config, `${config.prefix}${descriptor.index_key}`, fetchImpl).then(object => {
|
|
316
332
|
const index = parseCategoryIndex(object, descriptor, `${config.prefix}${descriptor.index_key}`);
|
|
317
|
-
shardSnapshots.push(`${descriptor.index_key}:${object.etag}`);
|
|
333
|
+
shardSnapshots.push(`${descriptor.index_key}:${object.etag}:${object.raw_sha256}`);
|
|
318
334
|
return index;
|
|
319
335
|
}));
|
|
320
336
|
}
|
|
@@ -333,23 +349,26 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
|
|
|
333
349
|
}
|
|
334
350
|
const index = await loadIndex(descriptor);
|
|
335
351
|
const semanticQuery = semanticQueryFor(input, slot);
|
|
336
|
-
const
|
|
337
|
-
|
|
352
|
+
const screen = screenDimensions(index.records, input.requirements);
|
|
353
|
+
const rankedIndex = screen.survivors.map(({ record: entry, check }) => ({
|
|
354
|
+
entry, check,
|
|
338
355
|
ranking: scoreIndexEntry(entry, semanticQuery, input.policy?.prefer_system_candidates !== false),
|
|
339
356
|
})).sort((a, b) => b.ranking.score - a.ranking.score || String(a.entry.model).localeCompare(String(b.entry.model)));
|
|
340
|
-
const shortlist = rankedIndex
|
|
357
|
+
const shortlist = shortlistByDimensionTier(rankedIndex, topK, maxFetch, item => item.check.status);
|
|
341
358
|
const loaded = await mapConcurrent(shortlist, concurrency, async item => ({
|
|
342
359
|
document: await wikiLoader(item.entry), indexRanking: item.ranking,
|
|
343
360
|
}));
|
|
344
|
-
const candidates = loaded.map(({ document, indexRanking }) => {
|
|
361
|
+
const candidates = orderByDimensionTier(loaded.map(({ document, indexRanking }) => {
|
|
345
362
|
const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
|
|
346
|
-
|
|
347
|
-
|
|
363
|
+
const candidate = candidateFromDocument(document, config, ranking, includeContent, indexRanking.score);
|
|
364
|
+
return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
|
|
365
|
+
}).sort((a, b) => b.semantic_score - a.semantic_score || b.index_score - a.index_score || String(a.model).localeCompare(String(b.model))), candidate => candidate.dimension_check.status);
|
|
348
366
|
if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
|
|
349
367
|
results.push({
|
|
350
368
|
slot_id: slot.slot_id,
|
|
351
369
|
category_path: slot.category_path,
|
|
352
370
|
retrieval_mode: "category_index",
|
|
371
|
+
dimension_screen: screen.summary,
|
|
353
372
|
available_documents: index.records.length,
|
|
354
373
|
indexed_candidates: index.records.length,
|
|
355
374
|
fetched_documents: shortlist.length,
|
|
@@ -361,19 +380,19 @@ async function searchWithCategoryIndexes({ input, env, config, catalogObject, fe
|
|
|
361
380
|
results,
|
|
362
381
|
unmatchedSlots,
|
|
363
382
|
unknowns: [],
|
|
364
|
-
sourceSnapshot: `tos:${config.bucket}:${config.prefix}:catalog:${catalogObject.etag}:indexes:${shardsDigest}`,
|
|
383
|
+
sourceSnapshot: `tos:${config.bucket}:${config.prefix}:catalog:${catalogObject.etag}:indexes:${shardsDigest}:content:${catalogObject.raw_sha256}`,
|
|
365
384
|
retrievalMode: "category_index",
|
|
366
385
|
};
|
|
367
386
|
}
|
|
368
387
|
|
|
369
|
-
async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, includeContent }) {
|
|
388
|
+
async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, maxFetch, includeContent }) {
|
|
370
389
|
const manifestKey = `${config.prefix}_manifest.json`;
|
|
371
390
|
const manifestObject = await getObject(config, manifestKey, fetchImpl);
|
|
372
391
|
const manifest = parseJsonObject(manifestObject, "WIKI_MANIFEST_INVALID", "TOS hardware Wiki manifest is not valid JSON", manifestKey);
|
|
373
392
|
if (!Array.isArray(manifest)) throw error("WIKI_MANIFEST_INVALID", "TOS hardware Wiki manifest must be an array", { key: manifestKey });
|
|
374
393
|
const scanLimit = Number.isInteger(input.policy?.scan_limit_per_slot) && input.policy.scan_limit_per_slot > 0
|
|
375
394
|
? input.policy.scan_limit_per_slot : null;
|
|
376
|
-
const wikiLoader = createWikiLoader(config, fetchImpl);
|
|
395
|
+
const wikiLoader = createWikiLoader(config, fetchImpl, { legacy: true });
|
|
377
396
|
const results = [];
|
|
378
397
|
const unmatchedSlots = [];
|
|
379
398
|
const unknowns = [{ code: "SEARCH_INDEX_FALLBACK", message: "category search catalog missing; legacy Manifest full scan was explicitly enabled" }];
|
|
@@ -385,10 +404,11 @@ async function searchWithLegacyManifest({ input, env, config, fetchImpl, topK, i
|
|
|
385
404
|
}
|
|
386
405
|
const loaded = await mapConcurrent(entries, envPositiveInteger(env, "TOS_SEARCH_CONCURRENCY", 8), wikiLoader);
|
|
387
406
|
const semanticQuery = semanticQueryFor(input, slot);
|
|
388
|
-
const candidates = loaded.map(document => {
|
|
407
|
+
const candidates = shortlistByDimensionTier(loaded.map(document => {
|
|
389
408
|
const ranking = scoreDocument(document, semanticQuery, input.policy?.prefer_system_candidates !== false);
|
|
390
|
-
|
|
391
|
-
|
|
409
|
+
const candidate = candidateFromDocument(document, config, ranking, includeContent, ranking.score);
|
|
410
|
+
return { ...candidate, dimension_check: evaluateDimensions(candidate, input.requirements) };
|
|
411
|
+
}).filter(candidate => candidate.dimension_check.status !== "incompatible").sort((a, b) => b.semantic_score - a.semantic_score || String(a.model).localeCompare(String(b.model))), topK, maxFetch, candidate => candidate.dimension_check.status);
|
|
392
412
|
if (candidates.length === 0) unmatchedSlots.push(slot.slot_id);
|
|
393
413
|
results.push({
|
|
394
414
|
slot_id: slot.slot_id, category_path: slot.category_path, retrieval_mode: "legacy_manifest_full_scan",
|
|
@@ -423,9 +443,9 @@ export async function searchHardware({ input, env, fetchImpl = fetch }) {
|
|
|
423
443
|
});
|
|
424
444
|
}
|
|
425
445
|
const searched = catalogObject
|
|
426
|
-
? await searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, includeContent })
|
|
427
|
-
: await searchWithLegacyManifest({ input, env, config, fetchImpl, topK, includeContent });
|
|
428
|
-
const id = `search-${sha256(JSON.stringify({ query: input.query ?? "", slots: input.slots, sourceSnapshot: searched.sourceSnapshot })).slice(0, 16)}`;
|
|
446
|
+
? await searchWithCategoryIndexes({ input, env, config, catalogObject, fetchImpl, topK, maxFetch: maxFullWikiFetch, includeContent })
|
|
447
|
+
: await searchWithLegacyManifest({ input, env, config, fetchImpl, topK, maxFetch: maxFullWikiFetch, includeContent });
|
|
448
|
+
const id = `search-${sha256(JSON.stringify({ query: input.query ?? "", request_digest: selectionRequestDigest(input), slots: input.slots, sourceSnapshot: searched.sourceSnapshot })).slice(0, 16)}`;
|
|
429
449
|
return {
|
|
430
450
|
id,
|
|
431
451
|
status: "completed",
|
|
@@ -3,7 +3,12 @@ import { sha256 } from "./public-page.mjs";
|
|
|
3
3
|
import { digest } from "./selection-request.mjs";
|
|
4
4
|
import { redactHardwareResponse } from "./response-redaction.mjs";
|
|
5
5
|
import { receiptRequestId, validVolcResult } from "./web-receipt.mjs";
|
|
6
|
-
|
|
6
|
+
// Plans cap count at 5 (P2-05.02). Evidence saved by <=0.4.0 used the old default of 10;
|
|
7
|
+
// provider-chain-contract still accepts that frozen shape when validating saved receipts.
|
|
8
|
+
export const volcMaxCount = 5;
|
|
9
|
+
const volcBody = (item, count) => ({ Query: item.query, SearchType: "web", Count: count, Filter: { NeedUrl: true, NeedContent: false }, ContentFormats: "markdown", QueryControl: { QueryRewrite: false } });
|
|
10
|
+
export const volcQueryRequest = item => volcBody(item, item.count ?? volcMaxCount);
|
|
11
|
+
export const legacyVolcQueryRequest = item => volcBody(item, item.count ?? 10);
|
|
7
12
|
export function validateVolcCapability(body) {
|
|
8
13
|
if (!text(body.Query) || !Number.isSafeInteger(body.Count) || body.Count < 1 || body.Count > 50) throw failure("HARDWARE_WEB_CAPABILITY", "Volc Query must be nonempty and Count 1–50; original input is retained without truncation");
|
|
9
14
|
}
|
|
@@ -22,7 +22,10 @@ export function positiveEnv(env, name, fallback) {
|
|
|
22
22
|
export function webConfig(env = {}) {
|
|
23
23
|
const dnsTransport = env.ABELIEN_HARDWARE_WEB_DNS_TRANSPORT || "system";
|
|
24
24
|
if (!["system", "doh"].includes(dnsTransport)) throw failure("HARDWARE_WEB_CONFIG", "web DNS transport must be system or doh");
|
|
25
|
-
|
|
25
|
+
// Prismer (query, URL-load and Parse) is temporarily off by default (P2-05.02).
|
|
26
|
+
const prismer = env.ABELIEN_HARDWARE_WEB_PRISMER || "disabled";
|
|
27
|
+
if (!["enabled", "disabled"].includes(prismer)) throw failure("HARDWARE_WEB_CONFIG", "Prismer switch must be enabled or disabled");
|
|
28
|
+
return { dnsTransport, prismer, timeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_SEARCH_TIMEOUT_MS", 30000),
|
|
26
29
|
pageTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PAGE_TIMEOUT_MS", 30000),
|
|
27
30
|
parseTimeoutMs: positiveEnv(env, "ABELIEN_HARDWARE_WEB_PARSE_TIMEOUT_MS", 120000) };
|
|
28
31
|
}
|
|
@@ -76,8 +76,13 @@ export async function searchProviderChain({ input, source, existing, env, readRe
|
|
|
76
76
|
if (attempt.kind === "parse" && text(receipt.response?.taskId)) { attempt.task_id = receipt.response.taskId; attempt.status = "pending"; }
|
|
77
77
|
await save();
|
|
78
78
|
};
|
|
79
|
+
const skipPrismer = async attempt => { Object.assign(attempt, { status: "unavailable", reason: "prismer_disabled" }); await save(); };
|
|
79
80
|
const chooseQuery = async (entry, item) => {
|
|
80
|
-
if (!entry.attempts.length) {
|
|
81
|
+
if (!entry.attempts.length) {
|
|
82
|
+
const attempt = makeAttempt(entry, "prismer", "query", entry.request);
|
|
83
|
+
if (config.prismer === "disabled") await skipPrismer(attempt);
|
|
84
|
+
else await dispatch(attempt, () => request("/api/context/load", attempt.request, config.timeoutMs));
|
|
85
|
+
}
|
|
81
86
|
if (permitsFallback(entry.attempts[0], true) && entry.attempts.length === 1) { const attempt = makeAttempt(entry, "volcengine", "query", volcQueryRequest(item)); await dispatch(attempt, () => volcWebSearch(attempt.request, { env, fetchImpl, timeoutMs: config.timeoutMs })); }
|
|
82
87
|
const selected = expectedSelection(entry, true);
|
|
83
88
|
if (!selected) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "original query effect is not known selectable");
|
|
@@ -131,7 +136,8 @@ export async function searchProviderChain({ input, source, existing, env, readRe
|
|
|
131
136
|
const operation = sourceOperation(page.source_url);
|
|
132
137
|
if (!page.attempts.length) {
|
|
133
138
|
const attempt = makeAttempt(page, "prismer", operation.kind, operation.request);
|
|
134
|
-
|
|
139
|
+
if (config.prismer === "disabled") await skipPrismer(attempt);
|
|
140
|
+
else await dispatch(attempt, () => request(operation.path, attempt.request, operation.kind === "parse" ? config.parseTimeoutMs : config.pageTimeoutMs));
|
|
135
141
|
}
|
|
136
142
|
if (permitsFallback(page.attempts[0], false) && page.attempts.length === 1) {
|
|
137
143
|
const attempt = makeAttempt(page, "direct_https", "direct_page", { url: page.source_url, method: "GET" });
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { onlyKeys, text, invalid, boundRecall } from "./web-common.mjs";
|
|
2
|
+
import { volcMaxCount } from "./volc-web-search.mjs";
|
|
3
|
+
|
|
4
|
+
const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
2
5
|
|
|
3
6
|
// Shared read-only plan validation. Do not normalize model intent or access
|
|
4
7
|
// credentials/configuration/checkpoints before this original-source binding.
|
|
5
|
-
|
|
8
|
+
// The shape check alone is used for a plan already frozen in saved evidence.
|
|
9
|
+
export async function validateFrozenHardwareWebPlan({ input, readResource }) {
|
|
6
10
|
onlyKeys(input, ["requirements", "slots", "recall_id", "unresolved_slots"], "web search input");
|
|
7
11
|
const source = await boundRecall(input, readResource), recall = source.body;
|
|
8
12
|
if (!Array.isArray(input.unresolved_slots)) invalid("unresolved_slots must be an array");
|
|
@@ -16,3 +20,14 @@ export async function validateHardwareWebPlan({ input, readResource }) {
|
|
|
16
20
|
for (const slot of input.slots) if (!(recall.results.find(result => result.slot_id === slot.slot_id)?.candidates?.length) && !unresolved.has(slot.slot_id)) invalid("every zero-candidate slot needs an unresolved query; preserve the requirement");
|
|
17
21
|
return { source };
|
|
18
22
|
}
|
|
23
|
+
// New plans (P2-05.02): English queries for Volc and at most 5 results per query.
|
|
24
|
+
export function validateNewHardwareWebPlan(input) {
|
|
25
|
+
for (const item of input.unresolved_slots) {
|
|
26
|
+
if (item.count !== undefined && item.count > volcMaxCount) invalid(`search Count must be an integer from 1 to ${volcMaxCount}`);
|
|
27
|
+
if (cjk.test(item.query)) invalid("search query must be written in English; keep model numbers and manufacturer names");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function validateHardwareWebPlan({ input, readResource }) {
|
|
31
|
+
const result = await validateFrozenHardwareWebPlan({ input, readResource });
|
|
32
|
+
validateNewHardwareWebPlan(input); return result;
|
|
33
|
+
}
|
|
@@ -6,7 +6,7 @@ import { prismerRequest } from "./prismer.mjs";
|
|
|
6
6
|
import { queryRequest, validateHardwareWebIntegrity, verifyCheckpointExtension, trustedParseResult } from "./web-integrity.mjs";
|
|
7
7
|
import { chainProtocol } from "./provider-chain-contract.mjs";
|
|
8
8
|
import { searchProviderChain } from "./web-provider-chain.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { validateFrozenHardwareWebPlan, validateNewHardwareWebPlan } from "./web-search-plan.mjs";
|
|
10
10
|
const provider = "prismer", protocol = "prismer-context-parse-v1";
|
|
11
11
|
const unresolvedAttempt = attempt => ["in_progress", "unknown", "failed"].includes(attempt?.status);
|
|
12
12
|
export async function verifyHardwareWebRecovery({ input, readResource, readBlob }) {
|
|
@@ -16,13 +16,15 @@ export async function verifyHardwareWebRecovery({ input, readResource, readBlob
|
|
|
16
16
|
await unchanged(source, readResource); await unchanged(saved, readResource); return { ok: true };
|
|
17
17
|
}
|
|
18
18
|
export async function searchHardwareWeb({ input, env = {}, readResource, fetchImpl = fetch, ports = {}, checkpoint, readBlob, writeBlob }) {
|
|
19
|
-
const { source } = await
|
|
19
|
+
const { source } = await validateFrozenHardwareWebPlan({ input, readResource });
|
|
20
20
|
const binding = { recall_id: source.id, recall_digest: digest(source), request_digest: selectionRequestDigest(input), requirements: input.requirements, slots: input.slots };
|
|
21
21
|
const planDigest = digest(input.unresolved_slots), id = `web-evidence-${digest({ recall_id: binding.recall_id, request_digest: binding.request_digest }).slice(0, 24)}`;
|
|
22
22
|
const storedExisting = await readResource(`hardware-web-evidence:${id}`);
|
|
23
23
|
const existing = resolveWebEvidence(storedExisting, readBlob);
|
|
24
24
|
const pack = body => packWebEvidence(body, writeBlob, !existing && env.AB_HARNESS_AUDIT_V1 === "true");
|
|
25
25
|
if (existing && (existing.kind !== "hardware-web-evidence" || existing.body?.recall_id !== binding.recall_id || existing.body.request_digest !== binding.request_digest)) invalid("existing evidence has a different original identity");
|
|
26
|
+
// A plan frozen in saved evidence keeps the rules it was accepted under.
|
|
27
|
+
if (existing?.body.plan_digest !== planDigest) validateNewHardwareWebPlan(input);
|
|
26
28
|
if (!existing || existing.body.protocol === chainProtocol) return searchProviderChain({ input, source, existing: storedExisting, env, readResource, fetchImpl, ports, checkpoint, readBlob, writeBlob });
|
|
27
29
|
if (existing && (existing.body.provider !== provider || existing.body.protocol !== protocol)) throw failure("HARDWARE_WEB_RECONCILIATION_REQUIRED", "retain the original provider evidence; provider migration cannot reissue its invocation");
|
|
28
30
|
validateHardwareWebIntegrity({ original: input, source, saved: storedExisting, readBlob, allowPartial: true, code: "HARDWARE_WEB_RECONCILIATION_REQUIRED" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: abelien-hardware-selection
|
|
3
|
-
description: 为已澄清的需求分层选择硬件分类,经真实 TOS
|
|
3
|
+
description: 为已澄清的需求分层选择硬件分类,经真实 TOS 召回、英文 Volc 搜索加来源直读的来源绑定补选(Prismer 默认关闭)、Agent 语义比较与本地 hardware.select 校验身份、数量和引用。用于硬件选型,不用于需求澄清、独立数据库兼容求解或实物验证。
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility:
|
|
6
6
|
- claude-code
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abelien-hardware-selection",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "本地硬件选型:真实 TOS 召回、Agent 证据判断与受信资源校验;不是数据库兼容性求解器",
|
|
5
5
|
"resources": ["hardware-selection"],
|
|
6
6
|
"requires": ["abelien-harness", "abelien-hardware"],
|
|
7
7
|
"capabilities": {
|
|
8
8
|
"hardware.select": {
|
|
9
|
-
"description": "回读同需求的真实召回资源,验证 Agent
|
|
9
|
+
"description": "回读同需求的真实召回资源,验证 Agent 选择的型号、数量、逐约束引用与兼容评估(电源与接口可由可信电气数据确定性判定,结构/散热/软件只提示),保存选型、可选尺寸占用摘要与兼容报告;未知不通过门禁",
|
|
10
10
|
"adapter": {
|
|
11
11
|
"type": "module",
|
|
12
12
|
"path": "scripts/select.mjs",
|