cangku/backend/test/alibaba1688SkuDimensionAiService.test.js

348 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
SKU_DIMENSION_MODEL,
analyzeAlibaba1688SkuDimensions,
buildDimensionAnalysisAttributes,
extractExplicitDimensionValues,
normalizeDimensionPayload,
requestDimensionChunk,
} = require('../src/services/alibaba1688SkuDimensionAiService');
function productRecord(skuCount = 2, overrides = {}) {
const skus = Array.from({ length: skuCount }, (_, index) => ({
index: index + 1,
name: `原木色 ${index + 1}号 120×60×75cm`,
specs: [
{ name: '颜色', value: '原木色' },
{ name: '尺寸', value: `规格${index + 1}` },
],
}));
return {
productId: '1073252781818',
title: '家用书桌',
category: '住宅家具',
skuCount,
product: {
attributes: [{ name: '材质', value: '实木' }],
skus,
},
...overrides,
};
}
function aiConfig() {
return {
enabled: true,
apiKey: 'server-only-key',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeoutMs: 120000,
};
}
test('normalizes out-of-order Qwen dimensions by stable SKU references and converts units', () => {
const requestRows = [
{ skuRef: 'S1', sourceSkuKey: '1:尺寸=大号', sourceIndex: 1 },
{ skuRef: 'S2', sourceSkuKey: '2:尺寸=小号', sourceIndex: 2 },
];
const result = normalizeDimensionPayload({
items: [
{ skuRef: 'S2', lengthCm: '1.2m', widthCm: '600mm', heightCm: '75cm' },
{ skuRef: 'S1', lengthCm: 140, widthCm: 70, heightCm: 76 },
],
}, requestRows);
assert.deepEqual(result.map((row) => row.skuRef), ['S1', 'S2']);
assert.deepEqual(
[result[1].lengthCm, result[1].widthCm, result[1].heightCm],
[120, 60, 75]
);
assert.throws(
() => normalizeDimensionPayload({ items: [{ skuRef: 'S1', lengthCm: 1, widthCm: 2, heightCm: 3 }] }, requestRows),
error => error.code === 'ALIBABA_1688_SKU_DIMENSION_AI_INCOMPLETE'
);
});
test('uses qwen3.7-flash with JSON mode, disabled thinking and a backend-only key', async () => {
let captured;
const requestRows = [{
skuRef: 'S1',
sourceSkuKey: '1:尺寸=120cm',
sourceIndex: 1,
skuName: '120×60×75cm',
specs: [{ name: '尺寸', value: '120cm' }],
}];
const result = await requestDimensionChunk(aiConfig(), {
productId: '1073252781818',
title: '家用书桌',
skus: requestRows,
}, requestRows, {
httpClient: {
async post(url, body, config) {
captured = { url, body, config };
return {
data: {
choices: [{ message: { content: '{"items":[{"skuRef":"S1","lengthCm":120,"widthCm":60,"heightCm":75}]}' } }],
usage: { total_tokens: 99 },
},
};
},
},
});
assert.equal(captured.body.model, SKU_DIMENSION_MODEL);
assert.equal(captured.body.model, 'qwen3.7-flash');
assert.deepEqual(captured.body.response_format, { type: 'json_object' });
assert.equal(captured.body.enable_thinking, false);
assert.equal(captured.body.temperature, 0);
assert.equal(captured.config.timeout, 60000);
assert.equal(captured.config.headers.Authorization, 'Bearer server-only-key');
assert.equal(result.rows[0].heightCm, 75);
assert.equal(result.usage.totalTokens, 99);
});
test('explicit SKU dimensions are hard constraints and trigger a corrected model retry', async () => {
const requestRows = [{
skuRef: 'S1',
sourceSkuKey: '1:颜色=双杆款-60cm',
sourceIndex: 1,
skuName: '双杆款-60cm',
specs: [{ name: '颜色', value: '双杆款-60cm' }],
}];
assert.deepEqual(extractExplicitDimensionValues(requestRows[0]), [60]);
assert.deepEqual(extractExplicitDimensionValues({ skuName: '120×60×75cm' }), [120, 60, 75]);
const prompts = [];
const responses = [
{ lengthCm: 152, widthCm: 26.3, heightCm: 38 },
{ lengthCm: 60, widthCm: 26.3, heightCm: 152 },
];
const result = await requestDimensionChunk(aiConfig(), {
productId: '994837637916',
title: '落地挂衣架',
skus: requestRows,
}, requestRows, {
delay: async () => {},
httpClient: {
async post(_url, body) {
prompts.push(body.messages[0].content);
const dimensions = responses[prompts.length - 1];
return {
data: {
choices: [{ message: { content: JSON.stringify({
items: [{ skuRef: 'S1', ...dimensions }],
}) } }],
},
};
},
},
});
assert.equal(prompts.length, 2);
assert.match(prompts[1], /上次结果未通过校验/);
assert.equal(result.rows[0].lengthCm, 60);
});
test('logistics volume and weight tables are excluded from product dimension evidence', () => {
const attributes = buildDimensionAnalysisAttributes({
attributes: [
{ name: '颜色', value: '长(cm) 宽(cm) 高(cm) 体积(cm³) 重量(g)' },
{ name: '双杆款-60cm', value: '152 26.30 38 151908.800 2800' },
{ name: '规格', value: '60/85' },
{ name: '材质', value: '楠竹' },
],
});
assert.deepEqual(attributes, [
{ name: '规格', value: '60/85' },
{ name: '材质', value: '楠竹' },
]);
});
test('analyzes every canonical SKU in batches and aggregates usage', async () => {
const product = productRecord(31);
const chunkSizes = [];
const result = await analyzeAlibaba1688SkuDimensions(product.productId, {
getProduct: async () => product,
getEncoding: async () => null,
getConfig: async () => aiConfig(),
concurrency: 1,
requestChunk: async (_config, context, rows) => {
chunkSizes.push(rows.length);
assert.ok(context.skus.every((row) => Array.isArray(row.explicitDimensionValuesCm)));
return {
rows: rows.map((row) => ({
...row,
lengthCm: 120,
widthCm: 60,
heightCm: 75,
confidence: 'high',
evidence: 'SKU明确标注',
})),
usage: { promptTokens: rows.length, completionTokens: rows.length * 2, totalTokens: rows.length * 3 },
};
},
});
assert.deepEqual(chunkSizes, [30, 1]);
assert.equal(result.model, 'qwen3.7-flash');
assert.equal(result.rows.length, 31);
assert.equal(result.batchCount, 2);
assert.equal(result.usage.totalTokens, 93);
assert.equal(new Set(result.rows.map((row) => row.sourceSkuKey)).size, 31);
});
test('already encoded and previously requested products never call Qwen again', async () => {
for (const encoding of [
{
status: 'encoded',
request: { skuItems: [{ lengthCm: 120, widthCm: 60, heightCm: 75 }, { lengthCm: 140, widthCm: 70, heightCm: 76 }] },
},
{
status: 'failed',
request: { skuItems: [{ lengthCm: 120, widthCm: 60, heightCm: 75 }, { lengthCm: 140, widthCm: 70, heightCm: 76 }] },
},
]) {
let modelCalls = 0;
const result = await analyzeAlibaba1688SkuDimensions('1073252781818', {
getProduct: async () => productRecord(),
getEncoding: async () => encoding,
getConfig: async () => {
modelCalls += 1;
return aiConfig();
},
requestChunk: async () => {
modelCalls += 1;
throw new Error('must not call Qwen');
},
});
assert.equal(modelCalls, 0);
assert.equal(result.rows.length, 2);
assert.equal(result.alreadyEncoded, encoding.status === 'encoded');
}
});
test('a legacy failed request with missing dimensions gets one safe AI completion path', async () => {
const product = productRecord();
let modelCalls = 0;
const result = await analyzeAlibaba1688SkuDimensions(product.productId, {
getProduct: async () => product,
getEncoding: async () => ({
status: 'failed',
request: {
skuItems: [
{ productName: 'first', colorSpec: 'first-spec' },
{ productName: 'second', colorSpec: 'second-spec', lengthCm: 140 },
],
},
}),
getConfig: async () => aiConfig(),
requestChunk: async (_config, _context, rows) => {
modelCalls += 1;
return {
rows: rows.map(row => ({
...row,
lengthCm: row.sourceIndex === 1 ? 120 : 140,
widthCm: 60,
heightCm: 75,
confidence: 'medium',
evidence: 'legacy request completion',
})),
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
};
},
});
assert.equal(modelCalls, 1);
assert.equal(result.alreadyRequested, true);
assert.equal(result.source, 'qwen');
assert.equal(result.rows.length, 2);
});
test('saved failed requests are rejected before AI when their fixed SKU snapshot changed', async () => {
const product = productRecord();
let modelCalls = 0;
await assert.rejects(
analyzeAlibaba1688SkuDimensions(product.productId, {
getProduct: async () => product,
getEncoding: async () => ({
status: 'failed',
sourceSkuCount: 2,
sourceSkuFingerprint: 'a'.repeat(64),
request: { skuItems: [{}, {}] },
}),
getConfig: async () => aiConfig(),
requestChunk: async () => {
modelCalls += 1;
throw new Error('must not call Qwen');
},
}),
error => error.code === 'ALIBABA_1688_SKU_CHANGED_SINCE_ENCODING_REQUEST'
);
assert.equal(modelCalls, 0);
});
test('an incomplete active request waits for lease expiry before AI retry', async () => {
const product = productRecord();
const encoding = {
status: 'encoding',
startedAt: '2026-08-19T00:00:00.000Z',
request: { skuItems: [{}, {}] },
};
await assert.rejects(
analyzeAlibaba1688SkuDimensions(product.productId, {
getProduct: async () => product,
getEncoding: async () => encoding,
now: new Date('2026-08-19T00:01:00.000Z').getTime(),
}),
error => error.code === 'ALIBABA_1688_ENCODING_IN_PROGRESS'
);
let modelCalls = 0;
const result = await analyzeAlibaba1688SkuDimensions(product.productId, {
getProduct: async () => product,
getEncoding: async () => encoding,
now: new Date('2026-08-19T01:00:00.000Z').getTime(),
getConfig: async () => aiConfig(),
requestChunk: async (_config, _context, rows) => {
modelCalls += 1;
return {
rows: rows.map(row => ({ ...row, lengthCm: 1, widthCm: 2, heightCm: 3 })),
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
};
},
});
assert.equal(modelCalls, 1);
assert.equal(result.alreadyRequested, true);
});
test('discards AI dimensions if the canonical SKU fingerprint changes during analysis', async () => {
const before = productRecord();
const after = productRecord(2, {
product: {
...before.product,
skus: before.product.skus.map((sku, index) => (
index === 0
? { ...sku, name: '重新采集后的规格', specs: [{ name: '尺寸', value: '新规格' }] }
: sku
)),
},
});
await assert.rejects(
analyzeAlibaba1688SkuDimensions(before.productId, {
getProduct: async () => before,
getLatestProduct: async () => after,
getEncoding: async () => null,
getConfig: async () => aiConfig(),
requestChunk: async (_config, _context, rows) => ({
rows: rows.map((row) => ({ ...row, lengthCm: 1, widthCm: 2, heightCm: 3 })),
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
}),
}),
error => error.code === 'ALIBABA_1688_SKU_CHANGED_DURING_AI_ANALYSIS'
);
});