abelien-harness 0.5.0 → 0.6.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 +1 -0
- package/README.md +11 -18
- package/package.json +2 -3
- package/skills/abelien-appearance/SKILL.md +1 -0
- package/skills/abelien-clarify/SKILL.md +12 -2
- package/skills/abelien-clarify/references/examples.md +22 -0
- package/skills/abelien-clarify/scripts/check_result.py +38 -3
- package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
- package/skills/abelien-guide/SKILL.md +1 -1
- package/skills/abelien-hardware/SKILL.md +4 -4
- package/skills/abelien-hardware/domain.json +1 -1
- package/skills/abelien-hardware/references/search-index-format.md +4 -2
- package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
- package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
- package/skills/abelien-hardware/scripts/electrical.mjs +62 -19
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +3 -5
- package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +17 -0
- package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
- package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
- package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +9 -6
- package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -32
- package/skills/abelien-hardware/scripts/web-common.mjs +4 -10
- package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
- package/skills/abelien-hardware/scripts/web-provider-chain.mjs +2 -1
- package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
- package/skills/abelien-hardware/scripts/web-search-plan.mjs +5 -1
- package/skills/abelien-hardware/scripts/web-search.mjs +4 -3
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +18 -10
- package/skills/abelien-hardware-selection/scripts/select.mjs +13 -5
- package/skills/abelien-harness/scripts/ab.mjs +24 -2
- package/skills/abelien-harness/scripts/web/common.mjs +20 -0
- package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
- package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
- package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
- package/skills/abelien-reference/SKILL.md +51 -0
- package/skills/abelien-reference/domain.json +92 -0
- package/skills/abelien-reference/fixtures/create.final.json +102 -0
- package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
- package/skills/abelien-reference/fixtures/restore.final.json +267 -0
- package/skills/abelien-reference/references/research-contract.md +74 -0
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
- package/skills/abelien-reference/scripts/evidence.mjs +60 -0
- package/skills/abelien-reference/scripts/finalize.mjs +116 -0
- package/skills/abelien-reference/scripts/measure.mjs +62 -0
- package/skills/abelien-reference/scripts/png.mjs +86 -0
- package/skills/abelien-reference/scripts/research.mjs +179 -0
- package/skills/abelien-structure/SKILL.md +2 -2
- package/skills/abelien-structure/domain.json +64 -0
- package/skills/abelien-structure/references/build123d-execution.md +1 -1
- package/skills/abelien-structure/references/handoff.md +1 -1
- package/skills/abelien-structure/worker/abelien_cad.py +585 -0
- package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
- package/skills/abelien-structure/worker/cad.py +1623 -0
- package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
- package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
- package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
- package/skills/abelien-structure/worker/component-fit.mjs +167 -0
- package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
- package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
- package/skills/abelien-structure/worker/component-target-render.py +58 -0
- package/skills/abelien-structure/worker/config.mjs +6 -0
- package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
- package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
- package/skills/abelien-structure/worker/engineering-policy.json +16 -0
- package/skills/abelien-structure/worker/engineering-repair.py +147 -0
- package/skills/abelien-structure/worker/engineering.py +324 -0
- package/skills/abelien-structure/worker/identity.mjs +98 -0
- package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
- package/skills/abelien-structure/worker/jobs.mjs +451 -0
- package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
- package/skills/abelien-structure/worker/main.mjs +102 -0
- package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
- package/skills/abelien-structure/worker/materials.mjs +52 -0
- package/skills/abelien-structure/worker/native-launcher.py +13 -0
- package/skills/abelien-structure/worker/native.mjs +157 -0
- package/skills/abelien-structure/worker/representation.mjs +47 -0
- package/skills/abelien-structure/worker/sandbox.mjs +41 -0
- package/skills/abelien-structure/worker/source-contract.mjs +143 -0
- package/skills/abelien-structure/worker/source_call_stats.py +59 -0
- package/skills/abelien-structure/worker/supervisor.mjs +38 -0
- package/skills/abelien-workspace/SKILL.md +11 -0
- package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
- package/skills/abelien-workspace/domain.json +25 -2
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
- package/skills/abelien-workspace/references/harness-binding.md +2 -2
- package/skills/abelien-workspace/references/user-journey.md +10 -0
- package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
- package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
- package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
- package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -2
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -2
- package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -2
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// reference.finalize(P2-13.04):核验 Agent 对已收集证据的判断,生成最终的 reference-evidence。
|
|
2
|
+
// Agent 只给判断与原文引用;引用必须逐字出现在已保存的页面正文里,尺寸数字必须出现在引用中,
|
|
3
|
+
// 比例判定由测量值与规格尺寸确定性计算,不接受 Agent 自填的判定。
|
|
4
|
+
import { text, record, digest } from "../../abelien-harness/scripts/web/common.mjs";
|
|
5
|
+
import { compareRatio } from "./measure.mjs";
|
|
6
|
+
import { validateEvidence, invalid } from "./evidence.mjs";
|
|
7
|
+
|
|
8
|
+
const normalize = value => value.replace(/\s+/g, " ").trim();
|
|
9
|
+
const numbersIn = value => new Set((value.match(/\d+(?:\.\d+)?/g) ?? []).map(Number));
|
|
10
|
+
const percent = value => `${Math.round(value * 1000) / 10}%`;
|
|
11
|
+
|
|
12
|
+
function checkKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
|
|
13
|
+
function words(value, label, max = 300) { if (!text(value) || value.length > max) invalid(`${label} must be 1–${max} chars`); return value.trim(); }
|
|
14
|
+
|
|
15
|
+
export async function finalizeReference({ input, readResource, readBlob }) {
|
|
16
|
+
checkKeys(input, ["evidence_id", "identity", "similar", "excluded", "specs"], "input");
|
|
17
|
+
for (const name of ["similar", "excluded"]) if (input[name] !== undefined && (!Array.isArray(input[name]) || input[name].length > 12)) invalid(`${name} must be up to 12 entries`);
|
|
18
|
+
if (typeof readResource !== "function" || typeof readBlob !== "function") throw Object.assign(new Error("trusted resource and content readers are required"), { code: "REFERENCE_HOST" });
|
|
19
|
+
if (!/^reference-evidence:ref-[0-9a-f]{16}$/.test(input.evidence_id ?? "")) invalid("evidence_id must name a collected reference-evidence resource");
|
|
20
|
+
const resource = await readResource(input.evidence_id);
|
|
21
|
+
if (resource?.kind !== "reference-evidence" || resource.body?.status !== "collected") invalid("evidence_id must name a collected reference-evidence resource");
|
|
22
|
+
const collected = validateEvidence(resource.body), body = structuredClone(collected);
|
|
23
|
+
const pages = new Map(body.pages.map(page => [page.id, page])), items = new Map(body.items.map(item => [item.id, item]));
|
|
24
|
+
const pageText = new Map();
|
|
25
|
+
const quoteOn = (quote, label) => {
|
|
26
|
+
checkKeys(quote, ["page_id", "text"], label);
|
|
27
|
+
const page = pages.get(quote.page_id);
|
|
28
|
+
if (!page || page.status !== "read" || !page.text_blob_ref) invalid(`${label}.page_id must name a page that was read`);
|
|
29
|
+
if (!pageText.has(page.id)) pageText.set(page.id, normalize(String(readBlob(page.text_blob_ref)?.text ?? "")));
|
|
30
|
+
const quoted = words(quote.text, `${label}.text`, 500);
|
|
31
|
+
if (!pageText.get(page.id).includes(normalize(quoted))) invalid(`${label}.text must appear verbatim in page ${page.id}`);
|
|
32
|
+
return { page_id: page.id, text: quoted };
|
|
33
|
+
};
|
|
34
|
+
const itemOf = (id, kinds, label) => {
|
|
35
|
+
const item = items.get(id);
|
|
36
|
+
if (!item || !kinds.includes(item.kind)) invalid(`${label} must name an item of kind ${kinds.join(" or ")}`);
|
|
37
|
+
return item;
|
|
38
|
+
};
|
|
39
|
+
const unknowns = [...body.unknowns];
|
|
40
|
+
|
|
41
|
+
let identity = null;
|
|
42
|
+
if (input.identity !== undefined) {
|
|
43
|
+
checkKeys(input.identity, ["candidates", "chosen_item_id"], "identity");
|
|
44
|
+
const list = input.identity.candidates ?? [];
|
|
45
|
+
if (!Array.isArray(list) || list.length > 6) invalid("identity.candidates must be up to 6 entries");
|
|
46
|
+
const measurement = body.user_image?.measurement ?? null;
|
|
47
|
+
const candidates = list.map((c, i) => {
|
|
48
|
+
const label = `identity.candidates[${i}]`;
|
|
49
|
+
checkKeys(c, ["item_id", "name", "dimensions_mm", "quote"], label);
|
|
50
|
+
itemOf(c.item_id, ["candidate"], `${label}.item_id`);
|
|
51
|
+
// Dimensions are optional: a page may name the model without giving its size. The quote is always required.
|
|
52
|
+
const dims = c.dimensions_mm === undefined || c.dimensions_mm === null ? null : c.dimensions_mm;
|
|
53
|
+
const quote = quoteOn(c.quote, `${label}.quote`);
|
|
54
|
+
if (dims) {
|
|
55
|
+
checkKeys(dims, ["width", "depth", "height"], `${label}.dimensions_mm`);
|
|
56
|
+
const keys = ["width", "height", ...(dims.depth === undefined ? [] : ["depth"])], found = numbersIn(quote.text);
|
|
57
|
+
for (const key of keys) if (!Number.isFinite(dims[key]) || dims[key] <= 0 || dims[key] > 5000) invalid(`${label}.dimensions_mm.${key} must be 0–5000 mm`);
|
|
58
|
+
for (const key of keys) if (!found.has(dims[key])) invalid(`${label}.quote must contain the ${key} value ${dims[key]}`);
|
|
59
|
+
}
|
|
60
|
+
return { item_id: c.item_id, name: words(c.name, `${label}.name`, 120), dimensions_mm: dims ? { ...dims } : null, quote, ratio_check: compareRatio(measurement, dims) };
|
|
61
|
+
});
|
|
62
|
+
const chosen = input.identity.chosen_item_id ?? null;
|
|
63
|
+
if (chosen === null ? candidates.length > 0 : !candidates.some(c => c.item_id === chosen)) invalid("identity.chosen_item_id must name one of the candidates (null only when there are none)");
|
|
64
|
+
const pick = candidates.find(c => c.item_id === chosen) ?? null;
|
|
65
|
+
let conflict = null;
|
|
66
|
+
if (pick?.ratio_check.status === "mismatch") conflict = `所选型号「${pick.name}」与参考图比例不符(实测 ${pick.ratio_check.measured},规格 ${pick.ratio_check.spec})`;
|
|
67
|
+
else {
|
|
68
|
+
const others = candidates.filter(c => c !== pick && c.ratio_check.status === "consistent" && c.name !== pick?.name);
|
|
69
|
+
if (others.length && pick?.ratio_check.status !== "consistent") conflict = `另有比例一致的候选:${others.map(c => c.name).join("、")}`;
|
|
70
|
+
}
|
|
71
|
+
if (!pick) unknowns.push("型号未能识别");
|
|
72
|
+
else if (pick.ratio_check.status === "unknown") unknowns.push(`型号比例未核对(${pick.ratio_check.reason})`);
|
|
73
|
+
for (const c of candidates) {
|
|
74
|
+
const item = items.get(c.item_id), r = c.ratio_check;
|
|
75
|
+
const ratio = r.status === "unknown" ? `比例未核对(${r.reason})` : `实测高宽比 ${r.measured},规格 ${r.spec},相差 ${percent(r.relative_error)}`;
|
|
76
|
+
if (r.status === "mismatch") item.verdict = { code: "ratio_mismatch", basis: ratio };
|
|
77
|
+
else if (c.item_id === chosen) item.verdict = { code: "closest", basis: `选定为「${c.name}」;${ratio}` };
|
|
78
|
+
item.title = c.name;
|
|
79
|
+
}
|
|
80
|
+
identity = { candidates, chosen_item_id: chosen, conflict };
|
|
81
|
+
} else if (body.intent !== "create") unknowns.push("没有提交识别判断,型号保持未知");
|
|
82
|
+
|
|
83
|
+
const similar = (input.similar ?? []).map((s, i) => {
|
|
84
|
+
const label = `similar[${i}]`;
|
|
85
|
+
checkKeys(s, ["item_id", "decision", "borrowed", "why", "not_copied"], label);
|
|
86
|
+
const item = itemOf(s.item_id, ["similar"], `${label}.item_id`);
|
|
87
|
+
if (!["borrow", "skip"].includes(s.decision)) invalid(`${label}.decision must be borrow or skip`);
|
|
88
|
+
const why = words(s.why, `${label}.why`);
|
|
89
|
+
if (s.decision === "borrow") {
|
|
90
|
+
const borrowed = words(s.borrowed, `${label}.borrowed`), notCopied = s.not_copied === undefined ? null : words(s.not_copied, `${label}.not_copied`);
|
|
91
|
+
item.verdict = { code: "borrowed", basis: `借用:${borrowed};原因:${why}${notCopied ? `;不照搬:${notCopied}` : ""}` };
|
|
92
|
+
return { item_id: s.item_id, decision: "borrow", borrowed, why, not_copied: notCopied };
|
|
93
|
+
}
|
|
94
|
+
item.verdict = { code: "not_copied", basis: why };
|
|
95
|
+
return { item_id: s.item_id, decision: "skip", borrowed: null, why, not_copied: null };
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
for (const [i, e] of (input.excluded ?? []).entries()) {
|
|
99
|
+
checkKeys(e, ["item_id", "reason"], `excluded[${i}]`);
|
|
100
|
+
const item = itemOf(e.item_id, ["candidate", "teardown", "similar"], `excluded[${i}].item_id`);
|
|
101
|
+
item.status = "excluded"; item.verdict = { code: "excluded", basis: words(e.reason, `excluded[${i}].reason`) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (input.specs !== undefined && (!Array.isArray(input.specs) || input.specs.length > 12)) invalid("specs must be up to 12 entries");
|
|
105
|
+
const specs = (input.specs ?? []).map((s, i) => {
|
|
106
|
+
checkKeys(s, ["title", "value", "quote"], `specs[${i}]`);
|
|
107
|
+
const quote = quoteOn(s.quote, `specs[${i}].quote`), page = pages.get(quote.page_id);
|
|
108
|
+
return { id: `sp${i + 1}`, kind: "spec", title: words(s.title, `specs[${i}].title`, 80), role: words(s.value, `specs[${i}].value`, 200),
|
|
109
|
+
source: { url: page.final_url ?? page.url, site: items.get(page.id)?.source?.site ?? "" }, image: null, verdict: null, status: "ready", query: null, quote };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const judgementDigest = digest(input);
|
|
113
|
+
return validateEvidence({ ...body, id: `${body.id}-f${judgementDigest.slice(0, 8)}`, status: "final", collected_id: body.id, judgement_digest: judgementDigest,
|
|
114
|
+
identity, similar, items: [...items.values(), ...specs.map(({ quote, ...item }) => ({ ...item, role: `${item.role}(引自 ${quote.page_id})` }))], spec_quotes: specs.map(s => ({ id: s.id, quote: s.quote })),
|
|
115
|
+
unknowns: [...new Set(unknowns)] });
|
|
116
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 轮廓测量(P2-13.04):对单色背景或透明背景的产品图取主体包围盒,得到高宽比。
|
|
2
|
+
// 只在背景可判定时给结论;背景杂乱、全空或解码失败都记为 unknown,并写明原因,不估算。
|
|
3
|
+
import { decodePng } from "./png.mjs";
|
|
4
|
+
|
|
5
|
+
export const MEASURE_METHOD = "silhouette-v1";
|
|
6
|
+
const COLOR_DELTA = 24, ALPHA_MIN = 32, BORDER_NOISE_MAX = 0.1;
|
|
7
|
+
const median = values => { const sorted = [...values].sort((a, b) => a - b); return sorted[sorted.length >> 1]; };
|
|
8
|
+
|
|
9
|
+
function borderPixels(width, height) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (let x = 0; x < width; x++) out.push(x, (height - 1) * width + x);
|
|
12
|
+
for (let y = 1; y < height - 1; y++) out.push(y * width, y * width + width - 1);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** rgba: 8-bit RGBA samples. Returns { status: "measured", ... } or { status: "unknown", reason }. */
|
|
17
|
+
export function measureSilhouette({ width, height, rgba }) {
|
|
18
|
+
if (width < 8 || height < 8) return { status: "unknown", reason: "image_too_small", method: MEASURE_METHOD };
|
|
19
|
+
const border = borderPixels(width, height);
|
|
20
|
+
const transparentBorder = border.filter(i => rgba[i * 4 + 3] < ALPHA_MIN).length / border.length;
|
|
21
|
+
let foreground, background;
|
|
22
|
+
if (transparentBorder > 0.9) {
|
|
23
|
+
background = { mode: "alpha" };
|
|
24
|
+
foreground = i => rgba[i * 4 + 3] >= ALPHA_MIN;
|
|
25
|
+
} else {
|
|
26
|
+
const bg = [0, 1, 2].map(c => median(border.map(i => rgba[i * 4 + c])));
|
|
27
|
+
const differs = i => Math.max(Math.abs(rgba[i * 4] - bg[0]), Math.abs(rgba[i * 4 + 1] - bg[1]), Math.abs(rgba[i * 4 + 2] - bg[2])) > COLOR_DELTA || rgba[i * 4 + 3] < ALPHA_MIN;
|
|
28
|
+
if (border.filter(differs).length / border.length > BORDER_NOISE_MAX) return { status: "unknown", reason: "background_not_uniform", method: MEASURE_METHOD };
|
|
29
|
+
background = { mode: "color", rgb: bg };
|
|
30
|
+
foreground = differs;
|
|
31
|
+
}
|
|
32
|
+
const rows = new Uint32Array(height), cols = new Uint32Array(width);
|
|
33
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) if (foreground(y * width + x)) { rows[y]++; cols[x]++; }
|
|
34
|
+
const rowMin = Math.max(2, Math.ceil(width * 0.005)), colMin = Math.max(2, Math.ceil(height * 0.005));
|
|
35
|
+
const top = rows.findIndex(n => n >= rowMin), bottom = rows.findLastIndex(n => n >= rowMin);
|
|
36
|
+
const left = cols.findIndex(n => n >= colMin), right = cols.findLastIndex(n => n >= colMin);
|
|
37
|
+
if (top < 0 || left < 0) return { status: "unknown", reason: "no_foreground", method: MEASURE_METHOD };
|
|
38
|
+
const w = right - left + 1, h = bottom - top + 1;
|
|
39
|
+
if (w < 8 || h < 8) return { status: "unknown", reason: "foreground_too_small", method: MEASURE_METHOD };
|
|
40
|
+
return { status: "measured", method: MEASURE_METHOD, image: { width, height }, bbox: { x: left, y: top, width: w, height: h },
|
|
41
|
+
aspect_h_over_w: Math.round(h / w * 10000) / 10000, background };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function measurePng(bytes) {
|
|
45
|
+
let image;
|
|
46
|
+
try { image = decodePng(bytes); } catch (error) { if (error?.code === "PNG_UNSUPPORTED") return { status: "unknown", reason: `png_${error.reason}`, method: MEASURE_METHOD }; throw error; }
|
|
47
|
+
return measureSilhouette(image);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Front-view check: the visible outline of an upright product is width × height.
|
|
52
|
+
* Returns the relative error between the measured and spec height/width ratios against a tolerance.
|
|
53
|
+
*/
|
|
54
|
+
export const RATIO_TOLERANCE = 0.1;
|
|
55
|
+
export function compareRatio(measurement, dimensions, tolerance = RATIO_TOLERANCE) {
|
|
56
|
+
if (measurement?.status !== "measured") return { status: "unknown", reason: "no_measurement" };
|
|
57
|
+
const { width, height } = dimensions ?? {};
|
|
58
|
+
if (!(width > 0) || !(height > 0)) return { status: "unknown", reason: "no_dimensions" };
|
|
59
|
+
const spec = height / width, measured = measurement.aspect_h_over_w, error = Math.abs(measured - spec) / spec;
|
|
60
|
+
return { status: error <= tolerance ? "consistent" : "mismatch", measured, spec: Math.round(spec * 10000) / 10000,
|
|
61
|
+
relative_error: Math.round(error * 10000) / 10000, tolerance };
|
|
62
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 零依赖 PNG 解码(P2-13.04):只用 Node 自带 zlib。支持非隔行的灰度、RGB、调色板、灰度+透明、RGBA,
|
|
2
|
+
// 位深按 PNG 规范(16 位取高字节);含 tRNS 透明。隔行(Adam7)与超大图明确拒绝,由调用方记为未测量,不编造。
|
|
3
|
+
import { inflateSync } from "node:zlib";
|
|
4
|
+
|
|
5
|
+
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
6
|
+
export const PNG_MAX_PIXELS = 40_000_000;
|
|
7
|
+
export const isPng = bytes => Buffer.isBuffer(bytes) && bytes.length >= 8 && bytes.subarray(0, 8).equals(SIGNATURE);
|
|
8
|
+
|
|
9
|
+
class PngError extends Error { constructor(reason, message) { super(message); this.code = "PNG_UNSUPPORTED"; this.reason = reason; } }
|
|
10
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
11
|
+
const DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
|
|
12
|
+
|
|
13
|
+
function paeth(a, b, c) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
|
14
|
+
|
|
15
|
+
/** Returns { width, height, rgba } with 8-bit RGBA samples. Throws PngError with a stable reason. */
|
|
16
|
+
export function decodePng(bytes) {
|
|
17
|
+
if (!isPng(bytes)) throw new PngError("not_png", "not a PNG file");
|
|
18
|
+
let offset = 8, header = null; const idat = []; let palette = null, trns = null;
|
|
19
|
+
while (offset + 8 <= bytes.length) {
|
|
20
|
+
const length = bytes.readUInt32BE(offset), type = bytes.toString("latin1", offset + 4, offset + 8);
|
|
21
|
+
const start = offset + 8, end = start + length;
|
|
22
|
+
if (end + 4 > bytes.length) throw new PngError("truncated", "PNG chunk is truncated");
|
|
23
|
+
const data = bytes.subarray(start, end);
|
|
24
|
+
if (type === "IHDR") {
|
|
25
|
+
header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4), depth: data[8], color: data[9], interlace: data[12] };
|
|
26
|
+
} else if (type === "PLTE") palette = data;
|
|
27
|
+
else if (type === "tRNS") trns = data;
|
|
28
|
+
else if (type === "IDAT") idat.push(data);
|
|
29
|
+
else if (type === "IEND") break;
|
|
30
|
+
offset = end + 4;
|
|
31
|
+
}
|
|
32
|
+
if (!header) throw new PngError("truncated", "PNG header is missing");
|
|
33
|
+
const { width, height, depth, color, interlace } = header;
|
|
34
|
+
if (!CHANNELS[color] || !DEPTHS[color].includes(depth)) throw new PngError("unsupported_format", `PNG color type ${color} depth ${depth} is not supported`);
|
|
35
|
+
if (interlace !== 0) throw new PngError("interlaced", "interlaced PNG is not supported");
|
|
36
|
+
if (!width || !height || width * height > PNG_MAX_PIXELS) throw new PngError("too_large", "PNG dimensions are out of range");
|
|
37
|
+
if (color === 3 && !palette) throw new PngError("truncated", "palette PNG has no PLTE");
|
|
38
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
39
|
+
const bitsPerPixel = CHANNELS[color] * depth, stride = Math.ceil(width * bitsPerPixel / 8), bpp = Math.max(1, bitsPerPixel >> 3);
|
|
40
|
+
if (raw.length < height * (stride + 1)) throw new PngError("truncated", "PNG image data is truncated");
|
|
41
|
+
const lines = Buffer.alloc(height * stride);
|
|
42
|
+
for (let y = 0; y < height; y++) {
|
|
43
|
+
const filter = raw[y * (stride + 1)], src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)), row = lines.subarray(y * stride, (y + 1) * stride);
|
|
44
|
+
const prev = y ? lines.subarray((y - 1) * stride, y * stride) : null;
|
|
45
|
+
for (let i = 0; i < stride; i++) {
|
|
46
|
+
const left = i >= bpp ? row[i - bpp] : 0, up = prev ? prev[i] : 0, upLeft = prev && i >= bpp ? prev[i - bpp] : 0;
|
|
47
|
+
const value = filter === 0 ? src[i] : filter === 1 ? src[i] + left : filter === 2 ? src[i] + up
|
|
48
|
+
: filter === 3 ? src[i] + ((left + up) >> 1) : filter === 4 ? src[i] + paeth(left, up, upLeft) : NaN;
|
|
49
|
+
if (Number.isNaN(value)) throw new PngError("corrupt", `unknown PNG filter ${filter}`);
|
|
50
|
+
row[i] = value & 0xff;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
54
|
+
const sample = (row, index) => {
|
|
55
|
+
if (depth === 8) return row[index];
|
|
56
|
+
if (depth === 16) return row[index * 2];
|
|
57
|
+
const perByte = 8 / depth, byte = row[Math.floor(index / perByte)], shift = 8 - depth * (index % perByte + 1);
|
|
58
|
+
return (byte >> shift) & ((1 << depth) - 1);
|
|
59
|
+
};
|
|
60
|
+
const scale = depth < 8 && color !== 3 ? 255 / ((1 << depth) - 1) : 1;
|
|
61
|
+
const transparentGray = trns && color === 0 ? trns.readUInt16BE(0) : null;
|
|
62
|
+
const transparentRgb = trns && color === 2 ? [trns.readUInt16BE(0), trns.readUInt16BE(2), trns.readUInt16BE(4)] : null;
|
|
63
|
+
for (let y = 0; y < height; y++) {
|
|
64
|
+
const row = lines.subarray(y * stride, (y + 1) * stride);
|
|
65
|
+
for (let x = 0; x < width; x++) {
|
|
66
|
+
const o = (y * width + x) * 4, n = CHANNELS[color];
|
|
67
|
+
if (color === 3) {
|
|
68
|
+
const index = sample(row, x);
|
|
69
|
+
rgba[o] = palette[index * 3]; rgba[o + 1] = palette[index * 3 + 1]; rgba[o + 2] = palette[index * 3 + 2];
|
|
70
|
+
rgba[o + 3] = trns && index < trns.length ? trns[index] : 255;
|
|
71
|
+
} else if (color === 0 || color === 4) {
|
|
72
|
+
const raw16 = depth === 16 ? row.readUInt16BE(x * n * 2) : sample(row, x * n);
|
|
73
|
+
const gray = Math.round(sample(row, x * n) * scale);
|
|
74
|
+
rgba[o] = rgba[o + 1] = rgba[o + 2] = gray;
|
|
75
|
+
rgba[o + 3] = color === 4 ? sample(row, x * n + 1) : transparentGray !== null && raw16 === transparentGray ? 0 : 255;
|
|
76
|
+
} else {
|
|
77
|
+
rgba[o] = sample(row, x * n); rgba[o + 1] = sample(row, x * n + 1); rgba[o + 2] = sample(row, x * n + 2);
|
|
78
|
+
if (color === 6) rgba[o + 3] = sample(row, x * n + 3);
|
|
79
|
+
else if (transparentRgb && depth === 8 && rgba[o] === transparentRgb[0] && rgba[o + 1] === transparentRgb[1] && rgba[o + 2] === transparentRgb[2]) rgba[o + 3] = 0;
|
|
80
|
+
else if (transparentRgb && depth === 16 && row.readUInt16BE(x * 6) === transparentRgb[0] && row.readUInt16BE(x * 6 + 2) === transparentRgb[1] && row.readUInt16BE(x * 6 + 4) === transparentRgb[2]) rgba[o + 3] = 0;
|
|
81
|
+
else rgba[o + 3] = 255;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { width, height, rgba };
|
|
86
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// reference.research(P2-13.04):按 Agent 给出的检索计划执行确定性的收集——Volc 检索、公开页面安全直读、
|
|
2
|
+
// 页面主图抓取、用户原图测量——全部落为证据,不做身份判断。判断由 Agent 读证据后经 reference.finalize 提交并核验。
|
|
3
|
+
// 同一请求重复运行直接返回已有结果,不重复产生检索费用。
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
5
|
+
import { text, record, digest, sha256 } from "../../abelien-harness/scripts/web/common.mjs";
|
|
6
|
+
import { decodeBody } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
7
|
+
import { isPng } from "./png.mjs";
|
|
8
|
+
import { measurePng } from "./measure.mjs";
|
|
9
|
+
import { SCHEMA, INTENTS, DEPTHS, PURPOSES, KIND_BY_PURPOSE, LIMITS, reader, imageReader, volc, invalid, stringList, site, validateEvidence } from "./evidence.mjs";
|
|
10
|
+
|
|
11
|
+
function normalizeLink(value) {
|
|
12
|
+
try { return reader.normalizePublicSourceUrl(value).href; } catch { invalid("links must be public HTTPS URLs without credentials"); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateResearchInput(input) {
|
|
16
|
+
if (!record(input)) invalid("input must be an object");
|
|
17
|
+
const allowed = ["intent", "keep", "changeable", "depth", "text", "links", "user_image", "plan"];
|
|
18
|
+
if (Object.keys(input).some(key => !allowed.includes(key))) invalid("input has unsupported fields");
|
|
19
|
+
if (!INTENTS.includes(input.intent)) invalid(`intent must be one of ${INTENTS.join(", ")}`);
|
|
20
|
+
if (input.depth !== undefined && input.depth !== null && !DEPTHS.includes(input.depth)) invalid(`depth must be one of ${DEPTHS.join(", ")} or null`);
|
|
21
|
+
if (!text(input.text) || input.text.length > LIMITS.textChars) invalid(`text must be the user's words (1–${LIMITS.textChars} chars)`);
|
|
22
|
+
const links = input.links === undefined ? [] : input.links;
|
|
23
|
+
if (!Array.isArray(links) || links.length > LIMITS.links) invalid(`links must be up to ${LIMITS.links} URLs`);
|
|
24
|
+
let userImage = null;
|
|
25
|
+
if (input.user_image !== undefined && input.user_image !== null) {
|
|
26
|
+
const image = input.user_image;
|
|
27
|
+
if (!record(image) || image.media_type !== "image/png" || typeof image.base64 !== "string") invalid("user_image must be {media_type: \"image/png\", base64}; the host converts other formats to PNG");
|
|
28
|
+
const bytes = Buffer.from(image.base64, "base64");
|
|
29
|
+
if (!isPng(bytes) || bytes.length > LIMITS.userImageBytes) invalid(`user_image must be a PNG up to ${LIMITS.userImageBytes} bytes`);
|
|
30
|
+
userImage = { bytes, sha256: sha256(bytes) };
|
|
31
|
+
}
|
|
32
|
+
const plan = input.plan ?? {};
|
|
33
|
+
if (!record(plan) || Object.keys(plan).some(key => !["queries", "max_pages", "max_images"].includes(key))) invalid("plan has unsupported fields");
|
|
34
|
+
const queries = plan.queries ?? [];
|
|
35
|
+
if (!Array.isArray(queries) || queries.length > LIMITS.queries) invalid(`plan.queries must be up to ${LIMITS.queries} entries`);
|
|
36
|
+
const normalizedQueries = queries.map((q, i) => {
|
|
37
|
+
if (!record(q) || !PURPOSES.includes(q.purpose) || !text(q.query) || q.query.length > LIMITS.queryChars) invalid(`plan.queries[${i}] needs purpose (${PURPOSES.join(", ")}) and a query up to ${LIMITS.queryChars} chars`);
|
|
38
|
+
if (q.count !== undefined && (!Number.isSafeInteger(q.count) || q.count < 1 || q.count > LIMITS.count)) invalid(`plan.queries[${i}].count must be 1–${LIMITS.count}`);
|
|
39
|
+
return { purpose: q.purpose, query: q.query.trim(), count: q.count ?? LIMITS.count };
|
|
40
|
+
});
|
|
41
|
+
const bounded = (value, max, fallback, label) => { if (value === undefined) return fallback; if (!Number.isSafeInteger(value) || value < 0 || value > max) invalid(`plan.${label} must be 0–${max}`); return value; };
|
|
42
|
+
return {
|
|
43
|
+
intent: input.intent, keep: stringList(input.keep ?? [], "keep"), changeable: stringList(input.changeable ?? [], "changeable"),
|
|
44
|
+
depth: input.depth ?? null, text: input.text.trim(), links: links.map(normalizeLink), userImage,
|
|
45
|
+
plan: { queries: normalizedQueries, max_pages: bounded(plan.max_pages, LIMITS.pages, 8, "max_pages"), max_images: bounded(plan.max_images, LIMITS.images, 6, "max_images") },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const titleOf = html => {
|
|
50
|
+
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
51
|
+
return match ? match[1].replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
52
|
+
};
|
|
53
|
+
function mainImageUrl(html, base) {
|
|
54
|
+
const patterns = [/<meta[^>]+(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["'][^>]*content=["']([^"']+)["']/i,
|
|
55
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|og:image:url|twitter:image)["']/i,
|
|
56
|
+
/<link[^>]+rel=["']image_src["'][^>]*href=["']([^"']+)["']/i];
|
|
57
|
+
for (const pattern of patterns) {
|
|
58
|
+
const match = pattern.exec(html);
|
|
59
|
+
if (match) { try { return new URL(match[1].replace(/&/g, "&"), base).href; } catch { return null; } }
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Run tasks with bounded concurrency; results keep input order. */
|
|
65
|
+
async function pool(list, task, limit = LIMITS.concurrency) {
|
|
66
|
+
const out = new Array(list.length); let next = 0;
|
|
67
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
|
|
68
|
+
while (next < list.length) { const index = next++; out[index] = await task(list[index], index); }
|
|
69
|
+
}));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Interleave search results so one query cannot use the whole page budget. */
|
|
74
|
+
function pageQueue(links, intent, searches, max) {
|
|
75
|
+
const queue = [], seen = new Set();
|
|
76
|
+
const push = entry => { if (queue.length < max && !seen.has(entry.url)) { seen.add(entry.url); queue.push(entry); } };
|
|
77
|
+
for (const url of links) push({ url, purpose: intent === "create" ? "similar" : "identify", search_id: null, query: null, search_title: null });
|
|
78
|
+
for (let rank = 0; queue.length < max; rank++) {
|
|
79
|
+
let added = false;
|
|
80
|
+
for (const search of searches) {
|
|
81
|
+
const result = search.results[rank];
|
|
82
|
+
if (!result) continue;
|
|
83
|
+
added = true;
|
|
84
|
+
try { push({ url: reader.normalizePublicSourceUrl(result.url).href, purpose: search.purpose, search_id: search.id, query: search.query, search_title: result.title }); } catch { /* not a public HTTPS result */ }
|
|
85
|
+
}
|
|
86
|
+
if (!added) break;
|
|
87
|
+
}
|
|
88
|
+
return queue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function researchReference({ input, env = {}, fetchImpl, readResource, writeBlob, ports = {} }) {
|
|
92
|
+
const request = validateResearchInput(input);
|
|
93
|
+
if (typeof writeBlob !== "function" || typeof readResource !== "function") throw Object.assign(new Error("trusted content writer and resource reader are required"), { code: "REFERENCE_HOST" });
|
|
94
|
+
const requestDigest = digest({ ...request, userImage: request.userImage?.sha256 ?? null });
|
|
95
|
+
const id = `ref-${requestDigest.slice(0, 16)}`;
|
|
96
|
+
const existing = await readResource(`reference-evidence:${id}`);
|
|
97
|
+
if (existing?.body?.status === "collected" && existing.body.request_digest === requestDigest) return existing.body;
|
|
98
|
+
|
|
99
|
+
const unknowns = [], items = [];
|
|
100
|
+
let userImage = null;
|
|
101
|
+
if (request.userImage) {
|
|
102
|
+
const blobRef = writeBlob({ media_type: "image/png", base64: request.userImage.bytes.toString("base64") });
|
|
103
|
+
const measurement = measurePng(request.userImage.bytes);
|
|
104
|
+
if (measurement.status !== "measured") unknowns.push(`用户原图未能测量轮廓(${measurement.reason})`);
|
|
105
|
+
userImage = { blob_ref: blobRef, sha256: request.userImage.sha256, media_type: "image/png", measurement };
|
|
106
|
+
}
|
|
107
|
+
items.push({ id: "u1", kind: "user_input", title: "你的输入", role: request.text, source: null,
|
|
108
|
+
image: userImage ? { blob_ref: userImage.blob_ref, sha256: userImage.sha256, media_type: "image/png" } : null, verdict: null, status: "ready", query: null });
|
|
109
|
+
|
|
110
|
+
const searches = [];
|
|
111
|
+
for (const [index, q] of request.plan.queries.entries()) {
|
|
112
|
+
const entry = { id: `s${index + 1}`, purpose: q.purpose, query: q.query, count: q.count, status: "completed", error_code: null, request_id: null, receipt_sha256: null, results: [] };
|
|
113
|
+
try {
|
|
114
|
+
const saved = await volc.volcWebSearch(volc.volcQueryRequest({ query: q.query, count: q.count }), { env, fetchImpl, timeoutMs: LIMITS.searchTimeoutMs });
|
|
115
|
+
entry.request_id = saved.request_id; entry.receipt_sha256 = saved.raw_sha256;
|
|
116
|
+
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) }));
|
|
117
|
+
if (!entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error?.code === "REFERENCE_WEB_CONFIG") throw error;
|
|
120
|
+
entry.status = "failed"; entry.error_code = error?.code ?? "REFERENCE_WEB_TRANSPORT";
|
|
121
|
+
unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
122
|
+
}
|
|
123
|
+
searches.push(entry);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const readOptions = { lookup: ports.lookup, pageRequest: ports.pageRequest, dnsRequest: ports.dnsRequest, timeoutMs: LIMITS.pageTimeoutMs };
|
|
127
|
+
const queue = pageQueue(request.links, request.intent, searches, request.plan.max_pages);
|
|
128
|
+
// Phase 1: read pages concurrently; results keep queue order.
|
|
129
|
+
const pages = await pool(queue, async (entry, index) => {
|
|
130
|
+
const page = { id: `p${index + 1}`, url: entry.url, purpose: entry.purpose, search_id: entry.search_id, status: "unavailable", reason: null, final_url: null, title: entry.search_title ?? "",
|
|
131
|
+
raw_sha256: null, text_blob_ref: null, text_sha256: null, excerpt: "", image_url: null };
|
|
132
|
+
try {
|
|
133
|
+
const read = await reader.fetchPublicPage(entry.url, { ...readOptions, maxBytes: LIMITS.pageBytes });
|
|
134
|
+
const html = decodeBody(Buffer.from(read.raw_base64, "base64"), read.content_type);
|
|
135
|
+
Object.assign(page, { status: read.status, reason: read.reason, final_url: read.final_url, raw_sha256: read.raw_sha256, title: titleOf(html) || page.title });
|
|
136
|
+
if (read.status === "read") {
|
|
137
|
+
page.text_blob_ref = writeBlob({ text: read.text }); page.text_sha256 = read.text_sha256; page.excerpt = read.text.slice(0, LIMITS.excerptChars);
|
|
138
|
+
page.image_url = read.content_type.includes("html") ? mainImageUrl(html, read.final_url) : null;
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
page.reason = reader.publicReadFailure(error)?.stage ?? error?.code ?? "read_failed";
|
|
142
|
+
if (!reader.publicReadFailure(error) && !String(error?.code ?? "").startsWith("REFERENCE_WEB_")) throw error;
|
|
143
|
+
}
|
|
144
|
+
return page;
|
|
145
|
+
});
|
|
146
|
+
for (const page of pages) if (page.status !== "read") unknowns.push(`页面不可用:${page.url}(${page.reason})`);
|
|
147
|
+
// Phase 2: main images for the first pages that have one, up to the image budget, fetched concurrently.
|
|
148
|
+
const wanted = pages.filter(page => page.image_url).slice(0, request.plan.max_images);
|
|
149
|
+
const images = await pool(wanted, async (page, index) => {
|
|
150
|
+
const entryImage = { id: `i${index + 1}`, url: page.image_url, page_id: page.id, status: "unavailable", reason: null, blob_ref: null, sha256: null, media_type: null, measurement: null };
|
|
151
|
+
try {
|
|
152
|
+
const bytes = await imageReader.fetchPublicBytes(page.image_url, { ...readOptions, maxBytes: LIMITS.imageBytes, contentTypes: ["image/png", "image/jpeg", "image/webp"] });
|
|
153
|
+
Object.assign(entryImage, { status: bytes.status, reason: bytes.reason });
|
|
154
|
+
if (bytes.status === "read") {
|
|
155
|
+
const media = bytes.content_type.split(";")[0].trim();
|
|
156
|
+
entryImage.blob_ref = writeBlob({ media_type: media, base64: bytes.raw_base64 }); entryImage.sha256 = bytes.raw_sha256; entryImage.media_type = media;
|
|
157
|
+
if (media === "image/png") entryImage.measurement = measurePng(Buffer.from(bytes.raw_base64, "base64"));
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
entryImage.reason = imageReader.publicReadFailure(error)?.stage ?? error?.code ?? "read_failed";
|
|
161
|
+
if (!imageReader.publicReadFailure(error) && !String(error?.code ?? "").startsWith("REFERENCE_WEB_")) throw error;
|
|
162
|
+
}
|
|
163
|
+
return entryImage;
|
|
164
|
+
});
|
|
165
|
+
const imageOf = new Map(images.filter(i => i.status === "read").map(i => [i.page_id, { blob_ref: i.blob_ref, sha256: i.sha256, media_type: i.media_type }]));
|
|
166
|
+
for (const [index, page] of pages.entries()) {
|
|
167
|
+
items.push({ id: page.id, kind: KIND_BY_PURPOSE[page.purpose], title: page.title || site(page.url) || page.url, role: page.purpose === "identify" ? "候选产品" : page.purpose === "teardown" ? "逆向资料" : "相似产品",
|
|
168
|
+
source: { url: page.final_url ?? page.url, site: site(page.final_url ?? page.url) ?? "" }, image: imageOf.get(page.id) ?? null, verdict: null,
|
|
169
|
+
status: page.status === "read" ? "ready" : "unavailable", query: queue[index].query });
|
|
170
|
+
}
|
|
171
|
+
if (request.intent !== "create" && !pages.some(p => p.purpose === "identify" && p.status === "read")) unknowns.push("没有读到可用于识别产品的页面,型号保持未知");
|
|
172
|
+
if (request.intent === "create" && !pages.some(p => p.purpose === "similar" && p.status === "read")) unknowns.push("没有读到相似产品页面,可改按声学、供电、交互等子系统参考");
|
|
173
|
+
|
|
174
|
+
return validateEvidence({
|
|
175
|
+
id, schema: SCHEMA, status: "collected", request_digest: requestDigest, intent: request.intent, keep: request.keep, changeable: request.changeable,
|
|
176
|
+
depth: request.depth, text: request.text, user_image: userImage, searches, pages, images, items, unknowns,
|
|
177
|
+
budget: { queries: searches.length, pages: pages.length, images: images.length },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -14,7 +14,7 @@ compatibility:
|
|
|
14
14
|
|
|
15
15
|
## 参与时机与交接
|
|
16
16
|
|
|
17
|
-
-
|
|
17
|
+
- 硬件未选定:先建立整机结构骨架,即按功能划分的分区与基准面(如声学腔与挡板、电子托板层与支柱、按键板、电池仓、运动关节),给出每个分区的可用空间(直径或盒体、高度预算、开口与朝向),连同用户需求及 appearance 外观目标中的尺寸、形态、开口、固定区域和运动空间约束一起供 hardware 检索;分区空间以对应槽位的 `fit_envelope` 交给 hardware:分区由锁定的外形或用户硬要求确定时 `basis` 记 `locked`(放不下的件会被拒绝),由结构推断时记 `inferred`(只影响排序),`zone` 写分区名;此时只建骨架与包络,不做完整建模,也不阻塞首张外观图。还原或修改现成产品且有拆解或官方结构资料时,骨架按来源架构建立;新创时可参考相似产品的架构;两者都标为推断。首张外观图之后,再结合真实器件尺寸确定托板、支柱、开孔和安装方式,与 hardware 往返迭代。
|
|
18
18
|
- 硬件候选出现:核对尺寸、CAD 和可复用整机结构,做初步布局;候选几何不能被称为已采用产品。
|
|
19
19
|
- 已有硬件起点:读取实际组件 ID、数量、来源及版本,复用资产并建立装配、轻量预览。
|
|
20
20
|
- 用户修改或工程结果返回:只处理受影响的参数、资产和检查。纯颜色变化通常不重建几何;轮廓、分件、开口、组件或运动改变则重判相关检查。
|
|
@@ -45,7 +45,7 @@ compatibility:
|
|
|
45
45
|
|
|
46
46
|
进程组清理保持 TERM → wait(2s) → KILL → wait。仅 Darwin 的 PermissionError 且原子进程已退出时,固定 `/bin/ps` 完整校验全表,证明原组无成员或全为同有效 UID 的 zombie 后才按已终止处理(该表接受 Darwin 的 `?` 未定运行态,它不使整表判废;原组自身成员为 `?` 仍不是 zombie,照旧失败);活后代、未知探针与其他错误仍失败。边界见[执行参考](references/build123d-execution.md)。
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
本包随附 Structure Worker(`worker/`)及其宿主 CLI adapter:`domain.json` 注册的 `structure.*` 能力只供宿主调用,依赖宿主设置的文件根目录与 Worker 凭据,缺少即拒绝;Agent 不直接调用它们,CAD 仍经宿主的受管工具提交。上面三个执行辅助工具是有界前台进程,不是跨请求的后台 Worker;中断后可核验并复用已完成产物,不能恢复一个已经消失的进程。
|
|
49
49
|
|
|
50
50
|
## 还原与工程边界
|
|
51
51
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "abelien-structure",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "结构域的宿主 CLI adapter:组件拟合、工程产物校验、目标件渲染与 Structure Worker 任务。只供宿主经 ab capability run 调用:文件只能在宿主设置的 ABELIEN_STRUCTURE_IO_ROOT 内读写,连 Worker 需宿主设置的 AGENT_STRUCTURE_ORIGIN / AGENT_STRUCTURE_TOKEN;缺少即拒绝。",
|
|
5
|
+
"resources": [],
|
|
6
|
+
"capabilities": {
|
|
7
|
+
"structure.engineering-contract": {
|
|
8
|
+
"description": "返回工程拟合检查器的版本、检查项、未检查项、产物类型与策略,供宿主核对已存工程结果",
|
|
9
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "engineeringContract" }
|
|
10
|
+
},
|
|
11
|
+
"structure.engineering-request-check": {
|
|
12
|
+
"description": "校验并归一化 component_fit 工程请求(不提交任务),返回归一化请求与三角形数",
|
|
13
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "engineeringRequestCheck" }
|
|
14
|
+
},
|
|
15
|
+
"structure.attachment-baseline-check": {
|
|
16
|
+
"description": "按同一 Worker 基线的装配清单与 STEP 校验附件基线,返回已核对的附件基线",
|
|
17
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "attachmentBaselineCheck" }
|
|
18
|
+
},
|
|
19
|
+
"structure.engineering-artifacts-check": {
|
|
20
|
+
"description": "校验工程拟合任务的产物字节与身份(stored=true 时按已存子集校验),返回报告、配方与装配",
|
|
21
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "engineeringArtifactsCheck" }
|
|
22
|
+
},
|
|
23
|
+
"structure.component-fit": {
|
|
24
|
+
"description": "把生成件装进同一基线的目标部位,生成装配预览 GLB 并返回拟合报告",
|
|
25
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "componentFit" }
|
|
26
|
+
},
|
|
27
|
+
"structure.component-fit-verify": {
|
|
28
|
+
"description": "按原始输入重算拟合,核对报告与装配 GLB 字节;不信任报告里自报的结论",
|
|
29
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "componentFitVerify" }
|
|
30
|
+
},
|
|
31
|
+
"structure.target-placement": {
|
|
32
|
+
"description": "由可信基线 GLB 推导生成件到目标部位局部坐标的刚体变换",
|
|
33
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "targetPlacement" }
|
|
34
|
+
},
|
|
35
|
+
"structure.target-image": {
|
|
36
|
+
"description": "请 Worker 在其运行时内渲染目标部位的单件图(协议 v3 /target-image),写出 PNG 与目标件 GLB",
|
|
37
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "targetImage" }
|
|
38
|
+
},
|
|
39
|
+
"structure.worker-health": {
|
|
40
|
+
"description": "读取 Worker 的协议版本、检查器版本与 runtimeDigest",
|
|
41
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "workerHealth" }
|
|
42
|
+
},
|
|
43
|
+
"structure.source-contract": {
|
|
44
|
+
"description": "读取 Worker 的建模源码契约(与 health 同一身份)",
|
|
45
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "sourceContract" }
|
|
46
|
+
},
|
|
47
|
+
"structure.job-submit": {
|
|
48
|
+
"description": "在宿主给定的 scope 下向 Worker 提交 CAD 或工程拟合任务;同 key 重复提交返回同一任务",
|
|
49
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "jobSubmit" }
|
|
50
|
+
},
|
|
51
|
+
"structure.job-lookup": {
|
|
52
|
+
"description": "按 upstream key 回读任务,用于提交响应丢失后的找回与状态轮询",
|
|
53
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "jobLookup" }
|
|
54
|
+
},
|
|
55
|
+
"structure.job-cancel": {
|
|
56
|
+
"description": "取消指定任务",
|
|
57
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "jobCancel" }
|
|
58
|
+
},
|
|
59
|
+
"structure.artifact-read": {
|
|
60
|
+
"description": "读取任务产物:媒体类型、头部哈希与字节哈希都须与宿主给出的预期一致才写出文件",
|
|
61
|
+
"adapter": { "type": "module", "path": "worker/cli-adapter.mjs", "export": "artifactRead" }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
核对实际 Python 解释器及 `build123d`、所用导出/渲染依赖能导入,记录真实工具版本。根据已安装版本的文档选择 API;无运行环境时交回明确缺口,不自动重装或升级共享环境。
|
|
13
13
|
|
|
14
|
-
统一毫米与坐标系,通常 X 宽、Y 高、Z 深、正面 +Z;运动产品若用 Z
|
|
14
|
+
统一毫米与坐标系,通常 X 宽、Y 高、Z 深、正面 +Z;运动产品若用 Z 向上,须在参数和产物清单同时声明。组件保持原生比例;来源坐标修正与装配放置分开记录。尺寸、壁厚、间隙和运动范围集中在参数文件,阈值来自材料/工艺/真实规格或本次要求,不写全局猜测值。组件位置以骨架基准面表达:坐落件写成“所在基准面 + 间隙”,基准面高度由下层组件的原生尺寸加间隙推导,不为每个组件手填独立的绝对坐标;手填常数只用于来源给定的外形或开口位置,并在参数文件注明依据。
|
|
15
15
|
|
|
16
16
|
## 两个执行阶段
|
|
17
17
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
## 领域分工
|
|
20
20
|
|
|
21
21
|
- hardware 判定型号、电气/接口兼容及覆盖;structure 验证具体空间、安装点、操作口和几何关系。hardware 的机械规格证据不能代替实际装配检查。
|
|
22
|
-
- 无法安装时,返回受影响 slot/component
|
|
22
|
+
- 无法安装时,返回受影响 slot/component、可用空间、干涉证据、替代尺寸约束及可选结构修改。没有用户授权改变硬要求时,不通过放宽要求解决。调整顺序取决于哪些约束被锁定:外形被还原目标或用户硬要求锁定时,先由 hardware 在原要求下换件,再调整骨架(分区、层高、朝向),改变外形必须交 guide 由用户取舍并记为外观偏差,不得自行放大外壳;外形未锁定时,在 appearance 认可的外观容差内调整外形与换件同等可选,把选择依据写入结果。
|
|
23
23
|
- 换型交回 hardware;算力/行为影响交回 algorithm。structure 不把局部设计建议写成新的已选 BOM。
|
|
24
24
|
- 复用整机时保持组件数量和身份,核对继承范围。自定义机械件另列,避免把整机 BOM 与其子组件重复采购。
|
|
25
25
|
- source BOM 是原产品证据,candidate BOM 是本次选型与装配;同功能替代不能写成原件。相关研究消费 hardware 的证据,不再启动第二套候选系统。
|