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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// algorithm.candidates(P2-03 算法并行车道):核验 Agent 读完 implementation 研究后为每个 reaction 提出的候选实现,
|
|
2
|
+
// 保存为 status=proposed 的 algorithm-research。这一步不依赖硬件,可在硬件选型进行中完成;与硬件的兼容核对留给
|
|
3
|
+
// algorithm.finalize 的 bind 操作。引用必须逐字出现在本次研究存下的正文里(规范化空白与全半角后比较);同一份实现资料
|
|
4
|
+
// 常常同时支撑几个 reaction,所以可以引用本次研究读到的任一页。只出现在检索摘要里的引用标为 search_snippet,可用于发现,
|
|
5
|
+
// 不能当作证据。一次提交里每个候选的问题一起报出,免得 Agent 一轮只改一处。
|
|
6
|
+
import { text, record, digest, failure } from "../../abelien-harness/scripts/web/common.mjs";
|
|
7
|
+
import { SCHEMA, IMPLEMENTATION_TYPES, EXECUTION_TARGETS, LIMITS, invalid, words, normalize, validateResearch } from "./evidence.mjs";
|
|
8
|
+
|
|
9
|
+
/** Field sets of schemas/algorithm-candidates.v1.json; a test keeps the two in step. */
|
|
10
|
+
export const CANDIDATE_FIELDS = Object.freeze(["reaction_id", "implementation_ref", "implementation_version", "implementation_type", "execution_target", "library_entry_id", "summary", "needs", "quotes"]);
|
|
11
|
+
export const NEEDS_FIELDS = Object.freeze(["compute", "memory", "storage", "runtime", "network", "interfaces", "sensors"]);
|
|
12
|
+
export const CANDIDATE_LIMITS = Object.freeze({ refChars: 80, versionChars: 60, interfaceChars: 120, issues: 8 });
|
|
13
|
+
const FLOATING = /^(latest|main|master|head|unknown|unversioned|stable|\*)$/i;
|
|
14
|
+
const LINK = /https?:\/\/|www\./i;
|
|
15
|
+
|
|
16
|
+
/** A stable name the binding and the PRD can repeat as is; where it came from and what it does belong in quotes and summary. */
|
|
17
|
+
function implementationRef(value, label) {
|
|
18
|
+
if (!text(value) || [...value.trim()].length > CANDIDATE_LIMITS.refChars || LINK.test(value) || /[\r\n]/.test(value))
|
|
19
|
+
invalid(`${label} must be a short name (1–${CANDIDATE_LIMITS.refChars} chars, no URL or explanation; put details in summary)`);
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The version the source itself states, or null when it states none; a floating label is not a version. */
|
|
24
|
+
function implementationVersion(value, label) {
|
|
25
|
+
if (value === undefined || value === null) return null;
|
|
26
|
+
if (!text(value) || [...value.trim()].length > CANDIDATE_LIMITS.versionChars || LINK.test(value) || FLOATING.test(value.trim()))
|
|
27
|
+
invalid(`${label} must be the definite version the source states (≤${CANDIDATE_LIMITS.versionChars} chars, not latest/main), or null`);
|
|
28
|
+
return value.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function onlyKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
|
|
32
|
+
|
|
33
|
+
/** What the implementation needs from hardware, in the source's own words; unknown stays null, never guessed. */
|
|
34
|
+
function readNeeds(needs, label) {
|
|
35
|
+
onlyKeys(needs, NEEDS_FIELDS, label);
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const key of ["compute", "memory", "storage", "runtime", "network"]) {
|
|
38
|
+
const value = needs[key];
|
|
39
|
+
if (value !== undefined && value !== null && (!text(value) || [...value].length > LIMITS.summaryChars)) invalid(`${label}.${key} must be null or 1–${LIMITS.summaryChars} chars`);
|
|
40
|
+
out[key] = value === undefined || value === null ? null : value.trim();
|
|
41
|
+
}
|
|
42
|
+
for (const key of ["interfaces", "sensors"]) {
|
|
43
|
+
const value = needs[key] ?? [];
|
|
44
|
+
if (!Array.isArray(value) || value.length > 20 || value.some(item => !text(item) || [...item].length > CANDIDATE_LIMITS.interfaceChars)) invalid(`${label}.${key} must list up to 20 strings of 1–${CANDIDATE_LIMITS.interfaceChars} chars`);
|
|
45
|
+
out[key] = value.map(item => item.trim());
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function proposeAlgorithmCandidates({ input, readResource, readBlob }) {
|
|
51
|
+
onlyKeys(input, ["research_id", "candidates", "unresolved", "unknowns"], "input");
|
|
52
|
+
if (typeof readResource !== "function" || typeof readBlob !== "function") throw Object.assign(new Error("resource and content readers are required"), { code: "ALGORITHM_HOST" });
|
|
53
|
+
if (!text(input.research_id) || !input.research_id.startsWith("algorithm-research:")) invalid("research_id must be an algorithm-research resource ID");
|
|
54
|
+
const resource = await readResource(input.research_id);
|
|
55
|
+
if (!resource || resource.kind !== "algorithm-research") invalid("research_id does not name saved algorithm research");
|
|
56
|
+
const research = validateResearch(resource.body);
|
|
57
|
+
if (research.status !== "collected" || research.purpose !== "implementation") invalid("candidates are proposed from collected implementation research");
|
|
58
|
+
const reactionIds = new Set(research.reactions.map(item => item.id));
|
|
59
|
+
const libraryIds = new Set((research.library ?? []).map(item => item.entry_id));
|
|
60
|
+
const pages = new Map(research.pages.filter(page => page.status === "read").map(page => [page.id, page]));
|
|
61
|
+
const texts = new Map();
|
|
62
|
+
const pageText = pageId => {
|
|
63
|
+
if (!texts.has(pageId)) texts.set(pageId, normalize(String(readBlob(pages.get(pageId).text_blob_ref)?.text ?? "")));
|
|
64
|
+
return texts.get(pageId);
|
|
65
|
+
};
|
|
66
|
+
const candidates = Array.isArray(input.candidates) ? input.candidates : invalid("candidates must be an array");
|
|
67
|
+
if (candidates.length > LIMITS.candidates) invalid(`candidates must list up to ${LIMITS.candidates} entries`);
|
|
68
|
+
const covered = new Set(), issues = [];
|
|
69
|
+
// Each candidate reports its first problem; all of them come back together in one VALIDATION.
|
|
70
|
+
const collect = (fn, at) => {
|
|
71
|
+
try { return fn(); } catch (error) { if (error?.code !== "VALIDATION") throw error; issues.push(error.message.startsWith(at) ? error.message : `${at}: ${error.message}`); return null; }
|
|
72
|
+
};
|
|
73
|
+
const accepted = candidates.map((candidate, index) => collect(() => {
|
|
74
|
+
const at = `candidates[${index}]`;
|
|
75
|
+
onlyKeys(candidate, CANDIDATE_FIELDS, at);
|
|
76
|
+
if (!reactionIds.has(candidate.reaction_id)) invalid(`${at}.reaction_id must name a researched reaction`);
|
|
77
|
+
if (!IMPLEMENTATION_TYPES.includes(candidate.implementation_type)) invalid(`${at}.implementation_type must be one of ${IMPLEMENTATION_TYPES.join(", ")}`);
|
|
78
|
+
if (!EXECUTION_TARGETS.includes(candidate.execution_target)) invalid(`${at}.execution_target must be one of ${EXECUTION_TARGETS.join(", ")}`);
|
|
79
|
+
if (candidate.library_entry_id !== undefined && candidate.library_entry_id !== null && !libraryIds.has(candidate.library_entry_id)) invalid(`${at}.library_entry_id must name an in-library algorithm given to the research`);
|
|
80
|
+
const quotes = Array.isArray(candidate.quotes) ? candidate.quotes : [];
|
|
81
|
+
// An in-library algorithm may stand on the library entry alone; anything else needs source quotes.
|
|
82
|
+
if ((!quotes.length && !candidate.library_entry_id) || quotes.length > LIMITS.quotes) invalid(`${at}.quotes must list 1–${LIMITS.quotes} source quotes unless library_entry_id is given`);
|
|
83
|
+
const checked = quotes.map((quote, q) => {
|
|
84
|
+
const where = `${at}.quotes[${q}]`;
|
|
85
|
+
onlyKeys(quote, ["page_id", "text"], where);
|
|
86
|
+
const page = pages.get(quote.page_id);
|
|
87
|
+
if (!page) invalid(`${where}.page_id must name a read page of this research`);
|
|
88
|
+
const said = words(quote.text, `${where}.text`, LIMITS.quoteChars);
|
|
89
|
+
if ([...normalize(said)].length < LIMITS.quoteMinChars) invalid(`${where}.text must be at least ${LIMITS.quoteMinChars} characters`);
|
|
90
|
+
if (!pageText(quote.page_id).includes(normalize(said))) invalid(`${where}.text must appear verbatim in page ${quote.page_id}`);
|
|
91
|
+
return { page_id: page.id, url: page.final_url ?? page.url, text: said, text_source: page.text_source };
|
|
92
|
+
});
|
|
93
|
+
const out = { reaction_id: candidate.reaction_id, implementation_ref: implementationRef(candidate.implementation_ref, `${at}.implementation_ref`),
|
|
94
|
+
implementation_version: implementationVersion(candidate.implementation_version, `${at}.implementation_version`),
|
|
95
|
+
implementation_type: candidate.implementation_type, execution_target: candidate.execution_target, library_entry_id: candidate.library_entry_id ?? null,
|
|
96
|
+
summary: words(candidate.summary, `${at}.summary`, LIMITS.summaryChars), needs: readNeeds(candidate.needs ?? {}, `${at}.needs`), quotes: checked,
|
|
97
|
+
evidence: checked.some(quote => quote.text_source === "page") ? "page" : candidate.library_entry_id ? "library" : "search_snippet" };
|
|
98
|
+
covered.add(candidate.reaction_id);
|
|
99
|
+
return out;
|
|
100
|
+
}, `candidates[${index}]`));
|
|
101
|
+
const unresolved = Array.isArray(input.unresolved) ? input.unresolved : [];
|
|
102
|
+
const unresolvedIds = new Set();
|
|
103
|
+
const gaps = unresolved.map((item, index) => collect(() => {
|
|
104
|
+
onlyKeys(item, ["reaction_id", "reason"], `unresolved[${index}]`);
|
|
105
|
+
if (!reactionIds.has(item.reaction_id) || covered.has(item.reaction_id) || unresolvedIds.has(item.reaction_id)) invalid(`unresolved[${index}].reaction_id must name a researched reaction without candidates, once`);
|
|
106
|
+
unresolvedIds.add(item.reaction_id);
|
|
107
|
+
return { reaction_id: item.reaction_id, reason: words(item.reason, `unresolved[${index}].reason`, LIMITS.summaryChars) };
|
|
108
|
+
}, `unresolved[${index}]`));
|
|
109
|
+
// A reaction whose only candidate was rejected above is reported there, not again here.
|
|
110
|
+
const rejected = new Set(candidates.filter((_, index) => accepted[index] === null).map(candidate => candidate?.reaction_id));
|
|
111
|
+
for (const id of reactionIds) if (!covered.has(id) && !unresolvedIds.has(id) && !rejected.has(id)) issues.push(`reaction ${id} needs at least one candidate or an unresolved reason`);
|
|
112
|
+
if (issues.length) throw failure("VALIDATION", [...issues.slice(0, CANDIDATE_LIMITS.issues), ...(issues.length > CANDIDATE_LIMITS.issues ? [`and ${issues.length - CANDIDATE_LIMITS.issues} more`] : [])].join("; "));
|
|
113
|
+
const unknowns = Array.isArray(input.unknowns) ? input.unknowns.map((item, index) => words(item, `unknowns[${index}]`, LIMITS.summaryChars)) : [];
|
|
114
|
+
const stable = { collected_id: resource.id, candidates: accepted, unresolved: gaps, unknowns };
|
|
115
|
+
const id = `alg-prop-${digest(stable).slice(0, 16)}`;
|
|
116
|
+
return validateResearch({ id, schema: SCHEMA, status: "proposed", request_digest: research.request_digest, purpose: research.purpose, collected_id: resource.id,
|
|
117
|
+
context: research.context, reactions: research.reactions, limits: research.limits, library: research.library, hardware: null,
|
|
118
|
+
searches: [], pages: research.pages, candidates: accepted, unresolved: gaps, unknowns: [...research.unknowns, ...unknowns] });
|
|
119
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// algorithm-research v1 的字段定义与校验(P2-03 算法并行车道)。研究分两种:implementation 只凭需求检索实现
|
|
2
|
+
// (与硬件选型并行),hardware 在硬件登记后检索已选型号的厂商资料。页面正文由 Harness 自己读取并按 sha256 存证,
|
|
3
|
+
// algorithm.finalize 的 bind 操作只认这里存下的正文,不认 Agent 自述。
|
|
4
|
+
import { createPublicReader } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
5
|
+
import { createVolcSearch } from "../../abelien-harness/scripts/web/volc.mjs";
|
|
6
|
+
import { redact } from "../../abelien-harness/scripts/web/redact.mjs";
|
|
7
|
+
import { failure, text, record } from "../../abelien-harness/scripts/web/common.mjs";
|
|
8
|
+
|
|
9
|
+
export const SCHEMA = "abelien.algorithm-research.v1";
|
|
10
|
+
export const PURPOSES = Object.freeze(["implementation", "hardware"]);
|
|
11
|
+
export const STATUSES = Object.freeze(["in_progress", "collected", "proposed"]);
|
|
12
|
+
export const IMPLEMENTATION_TYPES = Object.freeze(["firmware", "library", "model", "cloud_service"]);
|
|
13
|
+
export const EXECUTION_TARGETS = Object.freeze(["edge", "cloud", "hybrid"]);
|
|
14
|
+
/** Version of the collected shape, part of every request digest: a shape change collects afresh under a new id. */
|
|
15
|
+
export const RESEARCH_SHAPE = 1;
|
|
16
|
+
export const LIMITS = Object.freeze({ queries: 8, count: 5, pages: 12, library: 50, reactions: 20, textChars: 2000, queryChars: 200,
|
|
17
|
+
candidates: 40, quotes: 6, quoteChars: 400, quoteMinChars: 8, summaryChars: 500,
|
|
18
|
+
pageBytes: 8 * 1024 * 1024, excerptChars: 1200, pageTimeoutMs: 25000, pageConnectTimeoutMs: 8000, pageRoundMs: 90000, searchTimeoutMs: 30000, concurrency: 4 });
|
|
19
|
+
|
|
20
|
+
export const CODES = Object.freeze({ transport: "ALGORITHM_WEB_TRANSPORT", dns: "ALGORITHM_WEB_DNS", access: "ALGORITHM_WEB_URL", redirect: "ALGORITHM_WEB_PAGE",
|
|
21
|
+
timeout: "ALGORITHM_WEB_TIMEOUT", url: "ALGORITHM_WEB_URL", config: "ALGORITHM_WEB_CONFIG", readTimeout: "ALGORITHM_PUBLIC_READ_TIMEOUT",
|
|
22
|
+
capability: "ALGORITHM_WEB_CAPABILITY", provider: "ALGORITHM_WEB_PROVIDER", auth: "ALGORITHM_WEB_AUTH", rateLimit: "ALGORITHM_WEB_RATE_LIMIT", response: "ALGORITHM_WEB_RESPONSE" });
|
|
23
|
+
|
|
24
|
+
export const reader = createPublicReader({ codes: CODES, userAgent: "Abelien-Algorithm-Evidence/1", accept: "text/html,text/plain,text/markdown,application/xhtml+xml", decodeCharset: true });
|
|
25
|
+
export const volc = createVolcSearch({ codes: CODES, ownsError: error => Boolean(error?.code?.startsWith("ALGORITHM_WEB_")),
|
|
26
|
+
redact: (raw, env = {}) => redact(raw, [env.WEB_SEARCH_API_KEY].filter(text)), timeoutMessage: "algorithm network request timed out" });
|
|
27
|
+
|
|
28
|
+
export const invalid = message => { throw failure("VALIDATION", message); };
|
|
29
|
+
export const words = (value, label, max = LIMITS.textChars) => {
|
|
30
|
+
if (!text(value) || [...value].length > max) invalid(`${label} must be 1–${max} chars`);
|
|
31
|
+
return value.trim();
|
|
32
|
+
};
|
|
33
|
+
// NFKC folds full-width forms before whitespace collapses, as reference.finalize does for its quotes.
|
|
34
|
+
export const normalize = value => value.normalize("NFKC").replace(/\s+/g, " ").trim();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The research reads clarify's requirements as they are: reaction[] {id, text} and limits[] with strength; only the
|
|
38
|
+
* fields the research needs are kept, so the request digest does not move with unrelated requirement edits.
|
|
39
|
+
*/
|
|
40
|
+
export function readRequirements(requirements) {
|
|
41
|
+
if (!record(requirements)) invalid("requirements must be clarify's Requirements object");
|
|
42
|
+
const reactions = Array.isArray(requirements.reaction) ? requirements.reaction : [];
|
|
43
|
+
if (!reactions.length || reactions.length > LIMITS.reactions) invalid(`requirements.reaction must list 1–${LIMITS.reactions} reactions`);
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
const out = reactions.map((reaction, index) => {
|
|
46
|
+
if (!record(reaction) || !text(reaction.id) || ids.has(reaction.id)) invalid(`requirements.reaction[${index}] needs a unique id`);
|
|
47
|
+
ids.add(reaction.id);
|
|
48
|
+
return { id: reaction.id, text: words(reaction.text, `requirements.reaction[${index}].text`) };
|
|
49
|
+
});
|
|
50
|
+
const limits = Array.isArray(requirements.limits) ? requirements.limits : [];
|
|
51
|
+
const context = record(requirements.context) ? requirements.context.text ?? null : typeof requirements.context === "string" ? requirements.context : null;
|
|
52
|
+
return { context: text(context) ? context.trim() : null, reactions: out,
|
|
53
|
+
limits: limits.filter(record).map(limit => ({ id: limit.id ?? null, text: limit.text ?? null, strength: limit.strength ?? null })) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateResearch(body) {
|
|
57
|
+
if (!record(body) || body.schema !== SCHEMA || !STATUSES.includes(body.status) || !text(body.id) || !PURPOSES.includes(body.purpose)) invalid("algorithm research has an invalid schema, id, status or purpose");
|
|
58
|
+
for (const key of ["reactions", "searches", "pages", "unknowns"]) if (!Array.isArray(body[key])) invalid(`algorithm research ${key} must be an array`);
|
|
59
|
+
for (const [index, page] of body.pages.entries()) {
|
|
60
|
+
const at = `pages[${index}]`;
|
|
61
|
+
if (!record(page) || page.id !== `p${index + 1}` || !text(page.url) || !["read", "unavailable"].includes(page.status)) invalid(`${at} has an invalid id, url or status`);
|
|
62
|
+
if (page.status === "read" && !(/^sha256:[0-9a-f]{64}$/.test(page.text_blob_ref ?? "") && /^[0-9a-f]{64}$/.test(page.text_sha256 ?? "") && ["page", "search_snippet"].includes(page.text_source)))
|
|
63
|
+
invalid(`${at} is read but lacks its saved text, hash or text_source`);
|
|
64
|
+
}
|
|
65
|
+
if (body.status === "proposed") {
|
|
66
|
+
if (!text(body.collected_id) || !Array.isArray(body.candidates) || !Array.isArray(body.unresolved)) invalid("proposed research needs collected_id, candidates and unresolved");
|
|
67
|
+
}
|
|
68
|
+
return body;
|
|
69
|
+
}
|
|
@@ -27,22 +27,75 @@ function array(value, name) {
|
|
|
27
27
|
return value;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/** Field sets of schemas/algorithm-bindings.v1.json (what the Agent submits as `research` for operation=bind); a test keeps them in step. */
|
|
31
|
+
export const PRIMARY_SOURCE_TYPES = Object.freeze(["hardware_vendor", "implementation_official", "huggingface", "cloud_vendor"]);
|
|
32
|
+
export const COMPATIBILITY_KEYS = Object.freeze(["io", "runtime", "dependencies", "network", "license", "resources"]);
|
|
33
|
+
export const CHECK_STATUSES = Object.freeze(["pass", "conflict", "unknown"]);
|
|
34
|
+
export const BIND_FIELDS = Object.freeze({
|
|
35
|
+
research: ["bindings", "sources", "unknowns", "blocking_unknowns", "unavailable_sources"],
|
|
36
|
+
binding: ["reaction_id", "implementation_ref", "implementation_version", "implementation_type", "execution_target", "hardware_component_ids", "source_ids", "compatibility", "constraint_checks"],
|
|
37
|
+
check: ["status", "summary", "source_ids"],
|
|
38
|
+
constraint: ["constraint_id", "status", "summary", "source_ids"],
|
|
39
|
+
source: ["id", "research_id", "page_id", "source_type", "title", "publisher", "version", "excerpt", "hardware_component_ids", "implementation_ref"],
|
|
40
|
+
});
|
|
41
|
+
const RECEIPT_FIELDS = ["url", "accessed_at", "content_sha256", "evidence_ref"];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* operation=bind(P2-03 算法并行车道):候选来自 algorithm.candidates 保存的 proposed 研究。来源只写 research_id 与
|
|
45
|
+
* page_id,回执字段(url、accessed_at、content_sha256、evidence_ref)由 Harness 从自己存下的研究正文填写,提交里的同名字段
|
|
46
|
+
* 一律丢弃(检索摘要不算);宿主注入的 readEvidence 仍然优先用于核对。每条绑定须选该 reaction 已提议的候选之一。
|
|
47
|
+
* 硬件基线可以尚未 verified:此时照常核对,但结果固定为 needs_confirmation,并写明原因。
|
|
48
|
+
*/
|
|
49
|
+
async function prepareBind(input, { readResource, readBlob, readEvidence }) {
|
|
50
|
+
if (!text(input.proposed_id) || !input.proposed_id.startsWith("algorithm-research:")) throw error("bind needs proposed_id of saved algorithm candidates");
|
|
51
|
+
if (typeof readBlob !== "function") throw error("bind needs the store content reader");
|
|
52
|
+
const proposed = await readResource(input.proposed_id);
|
|
53
|
+
if (!proposed || proposed.kind !== "algorithm-research" || proposed.body?.status !== "proposed" || !text(proposed.body.collected_id)) throw error("proposed_id does not name proposed algorithm candidates");
|
|
54
|
+
const allowed = async id => {
|
|
55
|
+
if (id === proposed.body.collected_id) return true;
|
|
56
|
+
const resource = await readResource(id);
|
|
57
|
+
return resource?.kind === "algorithm-research" && resource.body?.status === "collected" && resource.body.purpose === "hardware"
|
|
58
|
+
&& resource.body.hardware?.selection_id === input.hardware_selection_id && resource.body.hardware?.revision === input.hardware_selection_revision;
|
|
59
|
+
};
|
|
60
|
+
const storeEvidence = async ref => {
|
|
61
|
+
const match = /^(algorithm-research:[A-Za-z0-9][A-Za-z0-9_-]{0,127})#(p\d{1,3})$/.exec(ref ?? "");
|
|
62
|
+
if (!match || !await allowed(match[1])) return null;
|
|
63
|
+
const page = (await readResource(match[1]))?.body?.pages?.find(item => item?.id === match[2]);
|
|
64
|
+
if (!page || page.status !== "read" || page.text_source !== "page" || !text(page.fetched_at)) return null;
|
|
65
|
+
return { url: page.final_url ?? page.url, content_sha256: page.text_sha256, fetched_at: page.fetched_at, content: String(readBlob(page.text_blob_ref)?.text ?? "") };
|
|
66
|
+
};
|
|
67
|
+
/** The source as submitted, minus any receipt fields, plus the receipt of the saved page it names (none when it names no read page). */
|
|
68
|
+
const withReceipt = async source => {
|
|
69
|
+
if (!record(source)) return source;
|
|
70
|
+
const rest = Object.fromEntries(Object.entries(source).filter(([key]) => !RECEIPT_FIELDS.includes(key)));
|
|
71
|
+
const ref = text(source.research_id) && text(source.page_id) ? `${source.research_id}#${source.page_id}` : null;
|
|
72
|
+
const page = ref ? await storeEvidence(ref) : null;
|
|
73
|
+
return page ? { ...rest, url: page.url, accessed_at: page.fetched_at, content_sha256: page.content_sha256, evidence_ref: ref } : rest;
|
|
74
|
+
};
|
|
75
|
+
return { readEvidence: readEvidence ?? storeEvidence, allowUnverified: true, proposed, withReceipt };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function finalizeAlgorithmSelection({ input, readResource, readEvidence, readBlob }) {
|
|
79
|
+
if (input?.operation !== undefined && !["select", "bind"].includes(input.operation)) throw error("operation must be select or bind");
|
|
33
80
|
if (typeof readResource !== "function") throw error("store-backed hardware baseline reader is required");
|
|
81
|
+
const bind = input?.operation === "bind" ? await prepareBind(input, { readResource, readBlob, readEvidence }) : null;
|
|
82
|
+
if (bind) readEvidence = bind.readEvidence;
|
|
83
|
+
const research = bind && record(input?.research) && !Array.isArray(input.research.candidates)
|
|
84
|
+
? { ...input.research, candidates: bind.proposed.body.candidates } : input?.research;
|
|
85
|
+
if (!text(input?.hardware_selection_id) || !text(input?.hardware_selection_revision)) throw error("hardware selection id and revision are required");
|
|
34
86
|
const baseline = await readResource(input.hardware_selection_id);
|
|
35
|
-
if (!baseline || baseline.kind !== "hardware-selection" || baseline.body?.status !== "verified") throw error("hardware baseline must be an existing verified hardware-selection resource");
|
|
87
|
+
if (!baseline || baseline.kind !== "hardware-selection" || (baseline.body?.status !== "verified" && !bind?.allowUnverified)) throw error("hardware baseline must be an existing verified hardware-selection resource");
|
|
36
88
|
if (baseline.body.revision !== input.hardware_selection_revision) throw error("hardware selection revision is stale");
|
|
37
89
|
const models = baseline.body.selected_models;
|
|
38
90
|
if (!Array.isArray(models) || !models.length || models.some(item => !text(item?.component_id) || !text(item?.manufacturer) || !text(item?.model))) throw error("hardware baseline needs exact component IDs, manufacturers and models");
|
|
39
91
|
if (!record(research)) throw error("research is required");
|
|
40
92
|
const bindings = array(research.bindings, "bindings");
|
|
41
93
|
const candidates = array(research.candidates, "candidates");
|
|
42
|
-
const sources = array(research.sources, "sources");
|
|
94
|
+
const sources = bind ? await Promise.all(array(research.sources, "sources").map(bind.withReceipt)) : array(research.sources, "sources");
|
|
43
95
|
const unavailableSources = array(research.unavailable_sources, "unavailable_sources");
|
|
44
96
|
const unknowns = array(research.unknowns, "unknowns");
|
|
45
97
|
const blockingUnknowns = [...array(research.blocking_unknowns, "blocking_unknowns")];
|
|
98
|
+
if (bind && baseline.body.status !== "verified") blockingUnknowns.push(`hardware baseline is ${baseline.body.status ?? "unverified"}; the binding stays to be confirmed`);
|
|
46
99
|
const issues = [];
|
|
47
100
|
const check = (condition, path, message) => { if (!condition) issues.push({ path, message }); };
|
|
48
101
|
const hardwareIds = new Set(models.map(item => item.component_id));
|
|
@@ -69,7 +122,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
69
122
|
if (text(limit?.id)) constraints.add(limit.id);
|
|
70
123
|
}
|
|
71
124
|
const sourceMap = new Map();
|
|
72
|
-
const primaryTypes = new Set(
|
|
125
|
+
const primaryTypes = new Set(PRIMARY_SOURCE_TYPES);
|
|
73
126
|
let retrievalVerified = sources.length > 0;
|
|
74
127
|
for (const [index, source] of sources.entries()) {
|
|
75
128
|
const at = `sources[${index}]`;
|
|
@@ -94,6 +147,8 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
94
147
|
if (!record(binding)) continue;
|
|
95
148
|
seen.add(binding.reaction_id);
|
|
96
149
|
check(text(binding.implementation_ref) && pinned(binding.implementation_version), at, "implementation needs a stable reference and pinned version");
|
|
150
|
+
if (bind) check(values(bind.proposed.body.candidates).some(item => item?.reaction_id === binding.reaction_id && item.implementation_ref === binding.implementation_ref),
|
|
151
|
+
at, "binding must pick one of the proposed candidates of its reaction (same implementation_ref)");
|
|
97
152
|
check(["firmware", "library", "model", "cloud_service"].includes(binding.implementation_type), at, "implementation_type is invalid");
|
|
98
153
|
check(["edge", "cloud", "hybrid"].includes(binding.execution_target), at, "execution_target is invalid");
|
|
99
154
|
const ids = values(binding.hardware_component_ids);
|
|
@@ -102,7 +157,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
102
157
|
const cited = values(binding.source_ids).map(id => sourceMap.get(id)).filter(Boolean);
|
|
103
158
|
for (const id of ids) check(cited.some(source => source.source_type === "hardware_vendor" && values(source.hardware_component_ids).includes(id)), at, `missing exact-device manufacturer evidence: ${id}`);
|
|
104
159
|
check(cited.some(source => ["implementation_official", "huggingface", "cloud_vendor"].includes(source.source_type) && source.implementation_ref === binding.implementation_ref && source.version === binding.implementation_version), at, "missing matching implementation-version evidence");
|
|
105
|
-
for (const key of
|
|
160
|
+
for (const key of COMPATIBILITY_KEYS) {
|
|
106
161
|
const evidence = binding.compatibility?.[key];
|
|
107
162
|
check(evidence?.status === "pass" && text(evidence.summary) && sourceRefs(evidence.source_ids), `${at}.compatibility.${key}`, "compatibility requires a passing evidence-backed assessment");
|
|
108
163
|
}
|
|
@@ -127,6 +182,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
127
182
|
hardware_selection_revision: input.hardware_selection_revision,
|
|
128
183
|
hardware_selection_digest: baseline.meta?.digest ?? `sha256:${digest(baseline.body)}`,
|
|
129
184
|
requirements: input.requirements ?? {}, reactions, research, quality_issues: issues,
|
|
185
|
+
...(bind ? { operation: "bind", proposed_id: input.proposed_id } : {}),
|
|
130
186
|
};
|
|
131
187
|
const hash = digest(stable);
|
|
132
188
|
return {
|
|
@@ -135,6 +191,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
|
|
|
135
191
|
hardware_selection_id: input.hardware_selection_id,
|
|
136
192
|
hardware_selection_revision: input.hardware_selection_revision,
|
|
137
193
|
hardware_selection_digest: stable.hardware_selection_digest,
|
|
194
|
+
...(bind ? { operation: "bind", proposed_id: input.proposed_id } : {}),
|
|
138
195
|
source_snapshot: `research:${hash.slice(0, 16)}`,
|
|
139
196
|
revision: `r-${hash.slice(0, 12)}`,
|
|
140
197
|
bindings, candidates, sources,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// algorithm.lane-state(P2-03 算法并行车道):只读。宿主在每个自动阶段开跑前调用,按本草稿工作目录里已存的资源回答
|
|
2
|
+
// 「当前需求有没有已提议的候选」「这些候选有没有绑定到当前硬件」。需求是否相同按 algorithm.research 的同一规范化规则
|
|
3
|
+
// 判断(落点、reaction、limits),宿主不必复制这条规则;结果映射成 workspace.lanes-next 的 artifacts。
|
|
4
|
+
import { text, record, digest } from "../../abelien-harness/scripts/web/common.mjs";
|
|
5
|
+
import { readRequirements, invalid } from "./evidence.mjs";
|
|
6
|
+
|
|
7
|
+
const sameRequirements = (body, wanted) => digest({ context: body.context ?? null, reactions: body.reactions ?? [], limits: body.limits ?? [] }) === digest(wanted);
|
|
8
|
+
const newest = list => [...list].sort((a, b) => String(b.meta?.updatedAt ?? "").localeCompare(String(a.meta?.updatedAt ?? "")))[0] ?? null;
|
|
9
|
+
|
|
10
|
+
export async function algorithmLaneState({ input, listResources }) {
|
|
11
|
+
if (!record(input) || Object.keys(input).some(key => !["requirements", "hardware_selection_id", "hardware_selection_revision"].includes(key))) invalid("input accepts requirements, hardware_selection_id and hardware_selection_revision only");
|
|
12
|
+
if (typeof listResources !== "function") throw Object.assign(new Error("store listing is required"), { code: "ALGORITHM_HOST" });
|
|
13
|
+
const hasHardware = input.hardware_selection_id !== undefined && input.hardware_selection_id !== null;
|
|
14
|
+
if (hasHardware && (!text(input.hardware_selection_id) || !input.hardware_selection_id.startsWith("hardware-selection:") || !text(input.hardware_selection_revision)))
|
|
15
|
+
invalid("hardware_selection_id must be a hardware-selection resource ID with its revision");
|
|
16
|
+
const normalized = readRequirements(input.requirements);
|
|
17
|
+
const wanted = { context: normalized.context, reactions: normalized.reactions, limits: normalized.limits };
|
|
18
|
+
const proposed = newest(listResources("algorithm-research").filter(item => item.body?.status === "proposed" && sameRequirements(item.body, wanted)));
|
|
19
|
+
const selection = proposed && hasHardware ? newest(listResources("algorithm-selection").filter(item => item.body?.operation === "bind" && item.body.proposed_id === proposed.id
|
|
20
|
+
&& item.body.hardware_selection_id === input.hardware_selection_id && item.body.hardware_selection_revision === input.hardware_selection_revision)) : null;
|
|
21
|
+
return {
|
|
22
|
+
requirements_digest: digest(wanted),
|
|
23
|
+
proposed_id: proposed?.id ?? null,
|
|
24
|
+
selection_id: selection?.id ?? null,
|
|
25
|
+
selection_status: selection?.body.status ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// algorithm.research(P2-03 算法并行车道):按 Agent 的检索计划执行确定性的收集——Volc 检索与公开页面安全直读——
|
|
2
|
+
// 全部落为证据,不做选择。两种用途:implementation 只凭需求检索实现资料,可与硬件选型同时开始;hardware 在硬件
|
|
3
|
+
// 登记后检索已选型号的厂商资料,查询必须指向选型资源里的 component_id。
|
|
4
|
+
// 付费检索逐条「先存 sent 再发送」,与 reference.research 同一规则:重跑复用已完成的检索,停在 sent 而没有结果的
|
|
5
|
+
// 检索要求对账,绝不自动重发;同一请求完成后重复运行直接返回已有结果。
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
import { text, record, digest, sha256, failure } from "../../abelien-harness/scripts/web/common.mjs";
|
|
8
|
+
import { decodeBody } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
9
|
+
import { SCHEMA, PURPOSES, RESEARCH_SHAPE, LIMITS, reader, volc, invalid, words, readRequirements, validateResearch } from "./evidence.mjs";
|
|
10
|
+
|
|
11
|
+
const reconcile = message => failure("ALGORITHM_WEB_RECONCILIATION_REQUIRED", message);
|
|
12
|
+
|
|
13
|
+
function readLibrary(library) {
|
|
14
|
+
if (library === undefined || library === null) return [];
|
|
15
|
+
if (!Array.isArray(library) || library.length > LIMITS.library) invalid(`library must list up to ${LIMITS.library} in-library algorithms`);
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
return library.map((entry, index) => {
|
|
18
|
+
if (!record(entry) || !text(entry.entry_id) || ids.has(entry.entry_id)) invalid(`library[${index}] needs a unique entry_id`);
|
|
19
|
+
ids.add(entry.entry_id);
|
|
20
|
+
return { entry_id: entry.entry_id, name: words(entry.name, `library[${index}].name`, 200), category: text(entry.category) ? entry.category.trim().slice(0, 200) : null };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The hardware the vendor queries may name: exact models of a hardware-selection resource, verified or not. */
|
|
25
|
+
async function readHardware(input, readResource) {
|
|
26
|
+
if (!text(input.hardware_selection_id) || !text(input.hardware_selection_revision)) invalid("hardware research needs hardware_selection_id and hardware_selection_revision");
|
|
27
|
+
const resource = await readResource(input.hardware_selection_id);
|
|
28
|
+
if (!resource || resource.kind !== "hardware-selection" || resource.body?.revision !== input.hardware_selection_revision) invalid("hardware selection is missing or its revision is stale");
|
|
29
|
+
const models = Array.isArray(resource.body.selected_models) ? resource.body.selected_models : [];
|
|
30
|
+
if (!models.length || models.some(item => !text(item?.component_id) || !text(item?.manufacturer) || !text(item?.model))) invalid("hardware selection needs exact component IDs, manufacturers and models");
|
|
31
|
+
return { selection_id: input.hardware_selection_id, revision: input.hardware_selection_revision, status: resource.body.status ?? null,
|
|
32
|
+
models: models.map(item => ({ component_id: item.component_id, manufacturer: item.manufacturer, model: item.model })) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateResearchInput(input) {
|
|
36
|
+
if (!record(input)) invalid("input must be an object");
|
|
37
|
+
const allowed = ["purpose", "requirements", "library", "hardware_selection_id", "hardware_selection_revision", "plan"];
|
|
38
|
+
if (Object.keys(input).some(key => !allowed.includes(key))) invalid("input has unsupported fields");
|
|
39
|
+
if (!PURPOSES.includes(input.purpose)) invalid(`purpose must be one of ${PURPOSES.join(", ")}`);
|
|
40
|
+
const plan = input.plan ?? {};
|
|
41
|
+
if (!record(plan) || Object.keys(plan).some(key => !["queries", "max_pages"].includes(key))) invalid("plan has unsupported fields");
|
|
42
|
+
const queries = plan.queries ?? [];
|
|
43
|
+
if (!Array.isArray(queries) || !queries.length || queries.length > LIMITS.queries) invalid(`plan.queries must list 1–${LIMITS.queries} queries`);
|
|
44
|
+
const maxPages = plan.max_pages === undefined ? 8 : plan.max_pages;
|
|
45
|
+
if (!Number.isSafeInteger(maxPages) || maxPages < 0 || maxPages > LIMITS.pages) invalid(`plan.max_pages must be 0–${LIMITS.pages}`);
|
|
46
|
+
return { purpose: input.purpose, requirements: readRequirements(input.requirements), library: input.purpose === "implementation" ? readLibrary(input.library) : [],
|
|
47
|
+
queries, maxPages };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Interleave search results so one query cannot use the whole page budget; counts what the cap left unread. */
|
|
51
|
+
function pageQueue(searches, max) {
|
|
52
|
+
const queue = [], seen = new Set();
|
|
53
|
+
let skipped = 0;
|
|
54
|
+
for (let rank = 0; ; rank++) {
|
|
55
|
+
let added = false;
|
|
56
|
+
for (const search of searches) {
|
|
57
|
+
const result = search.results[rank];
|
|
58
|
+
if (!result) continue;
|
|
59
|
+
added = true;
|
|
60
|
+
let url;
|
|
61
|
+
try { url = reader.normalizePublicSourceUrl(result.url).href; } catch { continue; }
|
|
62
|
+
if (seen.has(url)) continue;
|
|
63
|
+
seen.add(url);
|
|
64
|
+
if (queue.length < max) queue.push({ url, search, title: result.title, snippet: result.snippet ?? "" }); else skipped++;
|
|
65
|
+
}
|
|
66
|
+
if (!added) break;
|
|
67
|
+
}
|
|
68
|
+
return Object.assign(queue, { skipped });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function pool(list, task, limit = LIMITS.concurrency) {
|
|
72
|
+
const out = new Array(list.length); let next = 0, failed = false;
|
|
73
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
|
|
74
|
+
while (!failed && next < list.length) {
|
|
75
|
+
const index = next++;
|
|
76
|
+
try { out[index] = await task(list[index], index); } catch (error) { failed = true; throw error; }
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function researchAlgorithms({ input, env = {}, fetchImpl, readResource, writeBlob, ports = {}, checkpoint }) {
|
|
83
|
+
const request = validateResearchInput(input);
|
|
84
|
+
if (typeof writeBlob !== "function" || typeof readResource !== "function") throw failure("ALGORITHM_HOST", "trusted content writer and resource reader are required");
|
|
85
|
+
const hardware = request.purpose === "hardware" ? await readHardware(input, readResource) : null;
|
|
86
|
+
const reactionIds = new Set(request.requirements.reactions.map(item => item.id));
|
|
87
|
+
const componentIds = new Set(hardware?.models.map(item => item.component_id) ?? []);
|
|
88
|
+
const queries = request.queries.map((q, index) => {
|
|
89
|
+
const at = `plan.queries[${index}]`;
|
|
90
|
+
if (!record(q) || Object.keys(q).some(key => !["reaction_id", "component_id", "query", "count"].includes(key))) invalid(`${at} has unsupported fields`);
|
|
91
|
+
const query = words(q.query, `${at}.query`, LIMITS.queryChars);
|
|
92
|
+
if (q.count !== undefined && (!Number.isSafeInteger(q.count) || q.count < 1 || q.count > LIMITS.count)) invalid(`${at}.count must be 1–${LIMITS.count}`);
|
|
93
|
+
// Implementation queries belong to one reaction; vendor queries to one selected model.
|
|
94
|
+
if (request.purpose === "implementation" && (!reactionIds.has(q.reaction_id) || q.component_id !== undefined)) invalid(`${at} must name one requirements reaction_id and no component_id`);
|
|
95
|
+
if (request.purpose === "hardware" && (!componentIds.has(q.component_id) || (q.reaction_id !== undefined && !reactionIds.has(q.reaction_id)))) invalid(`${at} must name a selected component_id`);
|
|
96
|
+
return { reaction_id: q.reaction_id ?? null, component_id: q.component_id ?? null, query, count: q.count ?? LIMITS.count };
|
|
97
|
+
});
|
|
98
|
+
const requestDigest = digest({ purpose: request.purpose, requirements: request.requirements, library: request.library, hardware, queries, max_pages: request.maxPages, shape: RESEARCH_SHAPE });
|
|
99
|
+
const id = `alg-res-${requestDigest.slice(0, 16)}`;
|
|
100
|
+
const existing = await readResource(`algorithm-research:${id}`);
|
|
101
|
+
if (existing?.body?.status === "collected" && existing.body.request_digest === requestDigest) {
|
|
102
|
+
try { return validateResearch(existing.body); } catch { throw reconcile("saved research fails the current contract; reconcile it instead of re-running the paid searches"); }
|
|
103
|
+
}
|
|
104
|
+
const sameQuery = (entry, q, index) => record(entry) && entry.id === `s${index + 1}` && entry.query === q.query && entry.count === q.count
|
|
105
|
+
&& entry.reaction_id === q.reaction_id && entry.component_id === q.component_id;
|
|
106
|
+
if (existing && (existing.body?.status !== "in_progress" || existing.body.request_digest !== requestDigest || !Array.isArray(existing.body.searches)
|
|
107
|
+
|| existing.body.searches.length > queries.length || existing.body.searches.some((entry, index) => !sameQuery(entry, queries[index], index))))
|
|
108
|
+
throw reconcile("saved research does not match this request; reconcile the original invocation instead of re-running it");
|
|
109
|
+
// Fail before anything is marked sent: a missing or malformed key cannot have produced a charge.
|
|
110
|
+
if (!text(env.WEB_SEARCH_API_KEY) || /[\r\n\0]/.test(env.WEB_SEARCH_API_KEY)) throw failure("ALGORITHM_WEB_CONFIG", "WEB_SEARCH_API_KEY is required from the trusted host");
|
|
111
|
+
if (typeof checkpoint !== "function") throw failure("ALGORITHM_WEB_CHECKPOINT_REQUIRED", "paid search IO requires the original resource store checkpoint");
|
|
112
|
+
let expectedDigest = existing?.meta?.digest ?? null;
|
|
113
|
+
const save = async body => { expectedDigest = (await checkpoint(body, { expectedDigest })).meta.digest; };
|
|
114
|
+
|
|
115
|
+
const unknowns = [];
|
|
116
|
+
const base = { id, schema: SCHEMA, request_digest: requestDigest, purpose: request.purpose, context: request.requirements.context,
|
|
117
|
+
reactions: request.requirements.reactions, limits: request.requirements.limits, library: request.library, hardware };
|
|
118
|
+
const progress = { ...base, status: "in_progress", searches: structuredClone(existing?.body.searches ?? []), pages: [], unknowns: [] };
|
|
119
|
+
const searches = progress.searches;
|
|
120
|
+
const keepReceipt = (entry, saved) => {
|
|
121
|
+
if (!saved || typeof saved.raw !== "string") return;
|
|
122
|
+
if (saved.raw.includes(env.WEB_SEARCH_API_KEY)) throw failure("ALGORITHM_WEB_RESPONSE", "search receipt still contains the key after redaction");
|
|
123
|
+
Object.assign(entry, { request_id: saved.request_id ?? null, receipt_sha256: saved.raw_sha256, receipt_blob_ref: writeBlob({ text: saved.raw }), response_sha256: saved.response_sha256 ?? null, http_status: saved.http_status ?? null, received_at: saved.received_at ?? null });
|
|
124
|
+
};
|
|
125
|
+
for (const [index, q] of queries.entries()) {
|
|
126
|
+
let entry = searches[index];
|
|
127
|
+
if (entry && ["completed", "failed"].includes(entry.status)) {
|
|
128
|
+
if (entry.status === "completed" && !entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
129
|
+
if (entry.status === "failed") unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (entry?.dispatch_state === "sent") throw reconcile(`search ${entry.id} was sent but its outcome was never saved; reconcile it with the provider, it is not resent`);
|
|
133
|
+
const body = volc.volcQueryRequest({ query: q.query, count: q.count });
|
|
134
|
+
volc.validateVolcCapability(body);
|
|
135
|
+
entry = { id: `s${index + 1}`, reaction_id: q.reaction_id, component_id: q.component_id, query: q.query, count: q.count, request: body, dispatch_state: "sent",
|
|
136
|
+
sent_at: new Date().toISOString(), status: "in_progress", error_code: null, outcome: null, request_id: null, receipt_sha256: null, receipt_blob_ref: null, results: [] };
|
|
137
|
+
searches[index] = entry;
|
|
138
|
+
await save(progress);
|
|
139
|
+
try {
|
|
140
|
+
const saved = await volc.volcWebSearch(body, { env, fetchImpl, timeoutMs: LIMITS.searchTimeoutMs });
|
|
141
|
+
keepReceipt(entry, saved);
|
|
142
|
+
entry.status = "completed"; entry.outcome = "received";
|
|
143
|
+
entry.results = (saved.response.Result.WebResults ?? []).filter(r => text(r?.Url)).map((r, rank) => ({ rank: rank + 1, url: r.Url, title: String(r.Title ?? ""), snippet: String(r.Snippet ?? r.Summary ?? "").slice(0, 300) }));
|
|
144
|
+
if (!entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!String(error?.code ?? "").startsWith("ALGORITHM_WEB_")) throw error;
|
|
147
|
+
keepReceipt(entry, error.providerEvidence);
|
|
148
|
+
entry.status = "failed"; entry.error_code = error.code; entry.outcome = error.ambiguous || !error.providerEvidence ? "unknown" : "rejected";
|
|
149
|
+
unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
150
|
+
}
|
|
151
|
+
await save(progress);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const readOptions = { lookup: ports.lookup, pageRequest: ports.pageRequest, dnsRequest: ports.dnsRequest };
|
|
155
|
+
const queue = pageQueue(searches, request.maxPages);
|
|
156
|
+
const now = ports.now ?? Date.now, roundEnds = now() + LIMITS.pageRoundMs;
|
|
157
|
+
const readPage = async url => {
|
|
158
|
+
for (let attempt = 0; ; attempt++) {
|
|
159
|
+
const left = roundEnds - now();
|
|
160
|
+
if (left < LIMITS.pageConnectTimeoutMs) throw Object.assign(new Error("page round budget spent"), { code: "round_budget" });
|
|
161
|
+
try {
|
|
162
|
+
return await reader.fetchPublicPage(url, { ...readOptions, timeoutMs: Math.min(LIMITS.pageTimeoutMs, left), connectTimeoutMs: LIMITS.pageConnectTimeoutMs, maxBytes: LIMITS.pageBytes });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const stage = reader.publicReadFailure(error)?.stage;
|
|
165
|
+
if (attempt || !["timeout", "transport"].includes(stage)) throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const pages = await pool(queue, async (entry, index) => {
|
|
170
|
+
const page = { id: `p${index + 1}`, url: entry.url, final_url: null, search_id: entry.search.id, reaction_id: entry.search.reaction_id, component_id: entry.search.component_id,
|
|
171
|
+
status: "unavailable", reason: null, title: entry.title ?? "", fetched_at: null, text_blob_ref: null, text_sha256: null, text_source: null, excerpt: "" };
|
|
172
|
+
try {
|
|
173
|
+
const read = await readPage(entry.url);
|
|
174
|
+
Object.assign(page, { status: read.status, reason: read.reason, final_url: read.final_url, fetched_at: read.fetched_at ?? null });
|
|
175
|
+
if (read.status === "read") {
|
|
176
|
+
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(decodeBody(Buffer.from(read.raw_base64, "base64"), read.content_type))?.[1];
|
|
177
|
+
if (text(title)) page.title = title.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
178
|
+
Object.assign(page, { text_blob_ref: writeBlob({ text: read.text }), text_sha256: read.text_sha256, text_source: "page", excerpt: read.text.slice(0, LIMITS.excerptChars) });
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
page.reason = reader.publicReadFailure(error)?.stage ?? error?.code ?? "read_failed";
|
|
182
|
+
if (!reader.publicReadFailure(error) && error?.code !== "round_budget" && !String(error?.code ?? "").startsWith("ALGORITHM_WEB_")) throw error;
|
|
183
|
+
}
|
|
184
|
+
// An unreadable page still has the search engine's summary for discovery, marked as such; finalize never accepts it as evidence.
|
|
185
|
+
const snippet = String(entry.snippet ?? "").trim();
|
|
186
|
+
if (page.status !== "read" && snippet) {
|
|
187
|
+
Object.assign(page, { status: "read", text_blob_ref: writeBlob({ text: snippet }), text_sha256: sha256(snippet), text_source: "search_snippet", excerpt: snippet.slice(0, LIMITS.excerptChars) });
|
|
188
|
+
}
|
|
189
|
+
return page;
|
|
190
|
+
});
|
|
191
|
+
for (const reaction of request.requirements.reactions) {
|
|
192
|
+
if (request.purpose === "implementation" && !pages.some(page => page.reaction_id === reaction.id && page.text_source === "page"))
|
|
193
|
+
unknowns.push(`reaction ${reaction.id} 没有读到实现资料正文`);
|
|
194
|
+
}
|
|
195
|
+
for (const model of hardware?.models ?? []) {
|
|
196
|
+
if (queries.some(q => q.component_id === model.component_id) && !pages.some(page => page.component_id === model.component_id && page.text_source === "page"))
|
|
197
|
+
unknowns.push(`${model.manufacturer} ${model.model} 没有读到厂商资料正文`);
|
|
198
|
+
}
|
|
199
|
+
const collected = validateResearch({ ...base, status: "collected", searches, pages, unknowns,
|
|
200
|
+
budget: { queries: searches.length, pages: pages.length, skipped_pages: queue.skipped } });
|
|
201
|
+
await save(collected);
|
|
202
|
+
return collected;
|
|
203
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: abelien-hardware
|
|
3
|
-
description: 处理 Abelien 硬件分类、
|
|
3
|
+
description: 处理 Abelien 硬件分类、OSS Wiki 实时检索、英文 Volc 搜索加来源直读的来源绑定补选(Prismer 默认关闭)、整机复用、型号语义选型和已有方案局部修订。用于需求已澄清后的硬件阶段,不用于需求澄清或外观3D生成。
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility:
|
|
6
6
|
- claude-code
|
|
@@ -35,16 +35,16 @@ allowed-tools:
|
|
|
35
35
|
|
|
36
36
|
不要一次加载整个分类目录或大量 Wiki 全文。分类阶段只产出功能槽位、数量、受控三级路径和本次硬约束,不指定 SKU。
|
|
37
37
|
|
|
38
|
-
- 新建方案可先调用 `hardware.search`,按受控三级分类读取
|
|
38
|
+
- 新建方案可先调用 `hardware.search`,按受控三级分类读取 OSS `_catalog.json` 和对应的压缩分类索引,只下载索引 Top-K 的完整 Wiki;详细协议见[Wiki 检索契约](references/knowledge-recall-service-contract.md)。也可直接执行 `ab search_hardware <query> --category-path <l1/l2/l3>`。它只产生候选召回,不能替代正式选型服务。
|
|
39
39
|
- Agent 必须读取检索结果中的最新完整 Wiki,按功能槽位逐槽位选择零部件;同类整机配置只作参考,不先比较或套用整机(还原、基于现成产品修改或用户明确要求沿用整机时除外);只能提交候选中真实的 `component_id`。
|
|
40
40
|
- 候选比较如需保留,调用本地 `hardware.finalize` 归一化为 `hardware-proposal`;`proposed`/`needs_confirmation` 只代表候选提案,不能通过 `hardware-selection` 门,也不能直接开始算法阶段。
|
|
41
41
|
- 正式新建选型走同级 `abelien-hardware-selection` 的本地 `hardware.select`:回读真实召回资源校验 Agent 逐项证据判断,返回版本化 `hardware-selection`。`hardware.finalize` 本身仍不授予 verified。具体契约及未实现工程能力见其 selection-service-contract;主流程不需要 HTTP 服务地址。
|
|
42
42
|
- 已有方案只改指定部件时,先读[规划模式与修订边界](references/planning-modes.md),再调用 `hardware.revise`;未点名槽位保持不变。
|
|
43
43
|
- 接入或调试服务时读[选型服务契约](references/selection-service-contract.md)。
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
OSS 连接与 Web 共用变量 `CAD_STORAGE_REGION`、`CAD_STORAGE_ENDPOINT`、`CAD_STORAGE_BUCKET`、`CAD_STORAGE_ACCESS_KEY`、`CAD_STORAGE_SECRET_KEY`;可选 `CAD_STORAGE_ADDRESSING_STYLE`、`CAD_STORAGE_SESSION_TOKEN`,以及检索调参 `OSS_WIKI_PREFIX`、`OSS_SEARCH_CONCURRENCY`、`OSS_MAX_FULL_WIKI_FETCH_PER_SLOT`。每项只有这一个名字,旧 `TOS_*` 不再读取。CLI 会读取启动目录的 `.env`、`.env.local`,但进程变量优先;凭据不得写入 Skill、workflow、日志或结果。
|
|
46
46
|
|
|
47
|
-
发布索引时按[分类索引格式](references/search-index-format.md)构建,并按[搜索索引发布](references/search-index-release.md)执行版本化上传、Catalog 最后激活、远端校验和真实冒烟。迁移期只有显式设置 `
|
|
47
|
+
发布索引时按[分类索引格式](references/search-index-format.md)构建,并按[搜索索引发布](references/search-index-release.md)执行版本化上传、Catalog 最后激活、远端校验和真实冒烟。迁移期只有显式设置 `OSS_ALLOW_LEGACY_MANIFEST_FALLBACK=true` 才允许旧 Manifest 全文扫描,结果会标记降级。局部修订服务地址仍来自 `ABELIEN_HARDWARE_REVISE_URL`。未配置连接、分类路径不受控、版本冲突或证据不足时必须停止;不得按 `draft`、维护门禁或 CAD 状态排除候选。
|
|
48
48
|
|
|
49
49
|
## Online supplemental candidates
|
|
50
50
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abelien-hardware",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Hardware library recall, English Volc query and source evidence (Prismer off by default) with envelope candidates; formal selection remains hardware.select",
|
|
5
5
|
"resources": [
|
|
6
6
|
"hardware-recall",
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
],
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"hardware.search": {
|
|
16
|
-
"description": "按三级功能槽位读取
|
|
16
|
+
"description": "按三级功能槽位读取 OSS 压缩分类索引,先保守尺寸筛选再下载 Top-K Wiki,返回供 Agent 语义比较的完整候选页",
|
|
17
17
|
"adapter": {
|
|
18
18
|
"type": "module",
|
|
19
|
-
"path": "scripts/
|
|
19
|
+
"path": "scripts/oss-wiki-search.mjs",
|
|
20
20
|
"export": "searchHardware"
|
|
21
21
|
},
|
|
22
22
|
"input": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"produces": "hardware-recall"
|
|
50
50
|
},
|
|
51
51
|
"hardware.finalize": {
|
|
52
|
-
"description": "把
|
|
52
|
+
"description": "把 OSS Wiki 候选比较保存为硬件提案;不产生正式选型或 verified 结论",
|
|
53
53
|
"adapter": {
|
|
54
54
|
"type": "module",
|
|
55
55
|
"path": "scripts/finalize-selection.mjs",
|