新方案v1角色结构理解

This commit is contained in:
Ukawka
2026-09-17 17:44:24 +08:00
parent 2e79bc4a12
commit 30e82af4f1
21 changed files with 1287 additions and 14 deletions
+228
View File
@@ -0,0 +1,228 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync} from 'node:fs';
import {tmpdir} from 'node:os';
import path from 'node:path';
import {createServer} from 'node:http';
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {loadConfig, resolveStage, projectRoot, publicStage} from '../lib/config.mjs';
import {readImage} from '../lib/images.mjs';
import {validateParts, partsSchema} from '../lib/validate-parts.mjs';
import {requestJson, parseEventStream} from '../providers/http.mjs';
import {buildVisionRequest, extractVisionText} from '../providers/vision.mjs';
import {editImage, decodeEditedImages, validateEditInputs} from '../providers/image-edits.mjs';
import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs';
const exec = promisify(execFile);
const reference = path.join(projectRoot, 'references', 'characters', 'kenney-beige-idle.png');
const image = readImage(reference, {pngOnly: true});
const part = (id, parent = null) => ({id, semantic: id, parent, visibility: 'visible', bbox_norm_xyxy: [0.1, 0.1, 0.9, 0.9], pivot_norm: [0.5, 0.5], positive_points_norm: [[0.5, 0.5]], negative_points_norm: [], occluded_by: [], needs_completion: false, confidence: 0.8});
const goodParts = () => ({schema_version: 1, coordinate_space: 'normalized_xy_top_left', parts: [part('torso'), part('head', 'torso')], notes: []});
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {status, headers: {'Content-Type': 'application/json', 'x-request-id': 'test-request'}});
const stageFor = (name = 'understanding') => resolveStage(loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid/v1', VECTOR_ENGINE_API_KEY: 'test-secret'}}), name, name === 'understanding' ? {protocol: 'chat-completions'} : {});
const chatPayload = (parts = goodParts()) => ({model: 'gpt-6-astra', choices: [{finish_reason: 'stop', message: {content: JSON.stringify(parts)}}], usage: {total_tokens: 12}});
function temporary(t) {
const base = realpathSync(tmpdir());
const dir = mkdtempSync(path.join(base, 'ai-spine-test-'));
t.after(() => {
const resolved = realpathSync(dir);
assert.equal(path.dirname(resolved), base);
assert.ok(path.basename(resolved).startsWith('ai-spine-test-'));
rmSync(resolved, {recursive: true});
});
return dir;
}
test('model precedence, independent model selection, no aliases, no secrets in public config', () => {
const loaded = loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid', VECTOR_ENGINE_API_KEY: 'secret', SPINE_UNDERSTANDING_MODEL: 'env-vision', SPINE_COMPLETION_MODEL: 'env-image'}});
assert.equal(resolveStage(loaded, 'understanding').model, 'env-vision');
loaded.config.stages.understanding.model = 'config-vision';
assert.equal(resolveStage(loaded, 'understanding').model, 'config-vision');
assert.equal(resolveStage(loaded, 'understanding', {model: 'cli-vision'}).model, 'cli-vision');
assert.equal(resolveStage(loaded, 'understanding', {stream: false}).stream, false);
assert.equal(resolveStage(loaded, 'completion').model, 'env-image');
assert.equal(resolveStage(loaded, 'completion', {model: 'gpt-image-2.5'}).model, 'gpt-image-2.5');
assert.ok(!JSON.stringify(publicStage(resolveStage(loaded, 'understanding'))).includes('secret'));
loaded.config.stages.understanding.params = {model: 'sneaky'};
assert.throws(() => resolveStage(loaded, 'understanding'), /保留字段/u);
});
test('Chat and Responses payloads retain exact model/image/schema and parse structured text', () => {
const stage = stageFor();
stage.outputMode = 'json-schema';
let request = buildVisionRequest(stage, image, partsSchema);
assert.equal(request.route, 'chat/completions');
assert.equal(request.body.model, 'gpt-6-astra');
assert.ok(request.body.messages[0].content[1].image_url.url.startsWith('data:image/png;base64,'));
assert.equal(request.body.response_format.json_schema.schema, partsSchema);
stage.protocol = 'responses';
request = buildVisionRequest(stage, image, partsSchema);
assert.equal(request.route, 'responses');
assert.equal(request.body.text.format.type, 'json_schema');
assert.equal(request.body.input[0].content[1].type, 'input_image');
assert.equal(extractVisionText({status: 'completed', output: [{type: 'message', content: [{type: 'output_text', text: '{}'}]}]}, 'responses'), '{}');
assert.throws(() => extractVisionText({status: 'incomplete', output_text: '{}'}, 'responses'), /状态/u);
assert.throws(() => extractVisionText({choices: [{finish_reason: 'length', message: {content: '{}'}}]}, 'chat-completions'), /未完整/u);
});
test('schema + semantic validation reject invalid coordinates, references and both graph cycles', () => {
assert.equal(validateParts(goodParts()).valid, true);
for (const mutate of [
(d) => { d.parts[0].bbox_norm_xyxy = [0.9, 0, 0.1, 1]; },
(d) => { d.parts[0].confidence = 2; },
(d) => { d.parts[0].parent = 'missing'; },
(d) => { d.parts[0].parent = 'head'; },
(d) => { d.parts[0].occluded_by = ['head']; d.parts[1].occluded_by = ['torso']; },
(d) => { d.parts[1].id = 'torso'; },
(d) => { d.parts[0].visibility = 'hidden'; },
(d) => { d.parts[0].positive_points_norm = [[0, 0]]; },
(d) => { d.parts[0].unknown = 1; },
]) {
const data = goodParts(); mutate(data);
assert.equal(validateParts(data).valid, false);
}
});
test('SSE requires terminal completion, supports Responses and Chat, rejects truncated/error streams', async () => {
const payload = {status: 'completed', model: 'gpt-6-astra', output: [{content: [{type: 'output_text', text: '{}'}]}]};
const stream = `event: response.completed\r\ndata: ${JSON.stringify({type: 'response.completed', response: payload})}\r\n\r\ndata: [DONE]\r\n\r\n`;
assert.deepEqual(parseEventStream(stream), payload);
const result = await requestJson(stageFor(), 'responses', () => ({method: 'POST'}), {fetchImpl: async () => new Response(stream, {headers: {'content-type': 'text/event-stream'}})});
assert.equal(result.metadata.wire_format, 'sse');
assert.equal(extractVisionText(result.payload, 'responses'), '{}');
assert.throws(() => parseEventStream('data: {"type":"response.output_text.delta","delta":"{}"}\n\ndata: [DONE]\n\n'), /终止事件/u);
assert.throws(() => parseEventStream('data: {"type":"error","message":"bad"}\n\n'), /bad/u);
const chatStream = 'data: {"model":"chat","choices":[{"index":0,"delta":{"content":"{}"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n';
assert.equal(extractVisionText(parseEventStream(chatStream), 'chat-completions'), '{}');
});
test('HTTP retries explicit 429/5xx, not 400 or network errors; credentials redacted', async () => {
const stage = stageFor();
let count = 0;
const result = await requestJson(stage, 'models', () => ({method: 'GET'}), {
fetchImpl: async (url, options) => {
assert.equal(url, 'https://relay.invalid/v1/models');
assert.equal(options.headers.Authorization, 'Bearer test-secret');
count += 1;
return count < 3 ? jsonResponse({error: {message: 'retry'}}, count === 1 ? 429 : 500) : jsonResponse({data: []});
}, sleep: async () => {},
});
assert.equal(result.metadata.attempts, 3);
count = 0;
await assert.rejects(requestJson(stage, 'models', () => ({method: 'GET'}), {fetchImpl: async () => { count += 1; return jsonResponse({error: {message: 'test-secret'}}, 400); }}), (error) => !error.message.includes('test-secret'));
assert.equal(count, 1);
count = 0;
await assert.rejects(requestJson(stage, 'models', () => ({method: 'GET'}), {fetchImpl: async () => { count += 1; throw new Error('network'); }}), /network/u);
assert.equal(count, 1);
});
test('image adapter sends multipart, blocks unconfirmed capabilities, decodes inline and downloaded images', async () => {
const stage = stageFor('completion');
assert.throws(() => validateEditInputs(stage, [image, image]), /多图/u);
assert.throws(() => validateEditInputs(stage, [image], image), /掩码/u);
let captured;
await editImage(stage, {images: [image], prompt: 'test'}, {fetchImpl: async (url, request) => {
captured = request;
assert.ok(url.endsWith('/images/edits'));
return jsonResponse({data: [{b64_json: image.bytes.toString('base64')}]});
}});
assert.equal(captured.body.get('model'), 'qwen-image-3.0');
assert.equal(captured.body.get('n'), '1');
assert.equal(captured.body.get('image').type, 'image/png');
assert.equal(captured.headers['Content-Type'], undefined);
const decoded = await decodeEditedImages({data: [{b64_json: image.bytes.toString('base64')}, {url: 'https://cdn.invalid/output.png'}]}, stage, {fetchImpl: async (url, request) => {
assert.equal(request.headers, undefined);
return new Response(image.bytes);
}});
assert.equal(decoded.length, 2);
assert.equal(decoded[0].width, 128);
await assert.rejects(decodeEditedImages({data: [{b64_json: Buffer.from('<html>error</html>').toString('base64')}]}, stage), /图片数据/u);
await assert.rejects(decodeEditedImages({data: [{url: 'https://cdn.invalid/404'}]}, stage, {fetchImpl: async () => new Response('', {status: 404})}), /404/u);
});
test('understanding run saves report and provenance; resume checks inputs/model/artifacts', async (t) => {
const dir = temporary(t);
const stage = stageFor();
const loaded = loadConfig({envFile: '__no_test_env__', env: {}});
const outDir = path.join(dir, 'run');
const options = {loaded, stage, reference, outDir};
let calls = 0;
const transport = {fetchImpl: async () => { calls += 1; return jsonResponse(chatPayload()); }};
await runUnderstanding(options, transport);
const manifest = JSON.parse(readFileSync(path.join(outDir, 'manifest.json')));
assert.equal(manifest.status, 'completed');
assert.equal(manifest.quality_status, 'needs_review');
assert.equal(manifest.request.returned_model, 'gpt-6-astra');
assert.equal(manifest.inputs[0].width, 128);
assert.match(readFileSync(path.join(outDir, 'report.html'), 'utf8'), /尚未执行 SAM/u);
await assert.rejects(runUnderstanding(options, transport), /非空/u);
assert.equal((await runUnderstanding({...options, resume: true}, transport)).reused, true);
assert.equal(calls, 1);
await assert.rejects(runUnderstanding({...options, stage: {...stage, model: 'another-model'}, resume: true}, transport), /变化/u);
writeFileSync(path.join(outDir, 'semantic-parts.json'), '{}');
await assert.rejects(runUnderstanding({...options, resume: true}, transport), /被修改/u);
});
test('invalid/refused/truncated understanding stays failed, preserving raw responses', async (t) => {
const dir = temporary(t);
const loaded = loadConfig({envFile: '__no_test_env__', env: {}});
const invalid = chatPayload(); invalid.choices[0].message.content = 'not json';
const refused = {choices: [{message: {refusal: 'no'}}]};
for (const [i, payload] of [invalid, refused].entries()) {
const outDir = path.join(dir, `failed-${i}`);
await assert.rejects(runUnderstanding({loaded, stage: stageFor(), reference, outDir}, {fetchImpl: async () => jsonResponse(payload)}), /校验/u);
assert.equal(JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))).status, 'failed');
assert.deepEqual(JSON.parse(readFileSync(path.join(outDir, 'response.raw.json'))), payload);
assert.equal(JSON.parse(readFileSync(path.join(outDir, 'validation.json'))).valid, false);
}
});
test('SSE overload keeps redacted diagnostics and a failed manifest', async (t) => {
const outDir = path.join(temporary(t), 'overloaded');
const loaded = loadConfig({envFile: '__no_test_env__', env: {}});
await assert.rejects(runUnderstanding({loaded, stage: stageFor(), reference, outDir}, {fetchImpl: async () => new Response('data: {"type":"error","message":"overloaded test-secret"}\n\n', {headers: {'content-type': 'text/event-stream'}})}), /overloaded/u);
const diagnostic = readFileSync(path.join(outDir, 'response.error.json'), 'utf8');
assert.ok(!diagnostic.includes('test-secret'));
assert.match(diagnostic, /REDACTED/u);
assert.equal(JSON.parse(diagnostic).http_status, 200);
assert.equal(JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))).status, 'failed');
});
test('completion probe produces raw candidates and never claims final assets', async (t) => {
const dir = temporary(t);
const promptFile = path.join(dir, 'task.txt'); writeFileSync(promptFile, 'Complete this sleeve behind the torso.');
const outDir = path.join(dir, 'edit');
await runCompletionProbe({stage: stageFor('completion'), imagePaths: [reference], promptFile, outDir}, {fetchImpl: async () => jsonResponse({model: 'qwen-image-3.0', data: [{b64_json: image.bytes.toString('base64')}]})});
const manifest = JSON.parse(readFileSync(path.join(outDir, 'manifest.json')));
assert.equal(manifest.output_kind, 'raw_completion_probe');
assert.equal(manifest.quality_status, 'needs_review');
assert.equal(manifest.outputs[0].canvas_size_matches, true);
assert.deepEqual(readFileSync(path.join(outDir, 'candidate-1.png')), image.bytes);
});
test('pipeline new mode reaches a mock relay without Python; model listing and unsupported stages', async (t) => {
const dir = temporary(t);
let requests = 0;
const server = createServer(async (req, res) => {
requests += 1;
const chunks = []; for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString();
res.setHeader('Content-Type', 'application/json');
if (req.url === '/v1/models') res.end(JSON.stringify({data: [{id: 'relay-vision-test'}]}));
else if (req.url === '/v1/responses' && JSON.parse(body).model === 'relay-vision-test') res.end(JSON.stringify({model: 'relay-vision-test', status: 'completed', output: [{type: 'message', content: [{type: 'output_text', text: JSON.stringify(goodParts())}]}]}));
else { res.statusCode = 400; res.end('{}'); }
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const env = {...process.env, VECTOR_ENGINE_BASE_URL: `http://127.0.0.1:${server.address().port}`, VECTOR_ENGINE_API_KEY: 'mock-key'};
const outDir = path.join(dir, 'cli');
const result = await exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--reference', reference, '--out-dir', outDir, '--stop-after', 'understand', '--understanding-model', 'relay-vision-test', '--python', 'deliberately-nonexistent-python'], {cwd: projectRoot, env});
assert.match(result.stdout, /结构理解完成/u);
const list = await exec(process.execPath, ['tools/model-check.mjs', '--list-models'], {cwd: projectRoot, env});
assert.match(list.stdout, /relay-vision-test/u);
assert.equal(requests, 2);
await assert.rejects(exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--stop-after', 'segment'], {cwd: projectRoot, env}), (error) => /仅支持 understand/u.test(error.stderr));
assert.equal(requests, 2);
});