348 lines
11 KiB
JavaScript
348 lines
11 KiB
JavaScript
'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'
|
||
);
|
||
});
|