新方案v1角色结构理解
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user