297 lines
13 KiB
JavaScript
297 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { test } from 'node:test';
|
||
import { deepSeekJson, makeModel, validateCopy, validatePackage, runProductAiTask } from '../src/product-ai.ts';
|
||
|
||
test('SKU 用户要求独立进入高优先级输入,来源冲突和其他 SKU 不覆盖当前任务', async () => {
|
||
const previousFetch = globalThis.fetch;
|
||
const previousKey = process.env.DEEPSEEK_API_KEY;
|
||
process.env.DEEPSEEK_API_KEY = 'test-key';
|
||
let request;
|
||
let result;
|
||
const hint = '这是红色款,25cm。标题不要出现包邮。';
|
||
globalThis.fetch = async (_url, options) => {
|
||
request = JSON.parse(options.body);
|
||
return new Response(JSON.stringify({ choices: [{ finish_reason: 'stop', message: { content: JSON.stringify({
|
||
title: { ru: 'Красная фигурка 25 см', zh: '红色手办 25cm' },
|
||
tags: [{ ru: '#красная_фигурка', zh: '红色手办' }],
|
||
}) } }] }), { status: 200 });
|
||
};
|
||
const input = {
|
||
hint, variant_id: 'current', source_material: { title: '蓝色手办包邮 20cm', description: '蓝色', categories: [], attributes: [] },
|
||
profile: { title: '旧商品文案', tags: [], description: '旧描述' },
|
||
variants: [
|
||
{ id: 'current', display_name: '旧蓝色款', specification_values: [{ color: '蓝色' }] },
|
||
{ id: 'other', display_name: '其他SKU的专属角色', specification_values: [{ role: '其他角色' }] },
|
||
],
|
||
};
|
||
const tx = { query: async (sql, values) => {
|
||
if (sql.includes('SELECT kind, status')) return { rows: [{ kind: 'sku_copy', status: 'queued', input_snapshot: input }] };
|
||
if (sql.includes('result = $3::jsonb')) result = JSON.parse(values[2]);
|
||
return { rows: [] };
|
||
} };
|
||
try {
|
||
await runProductAiTask({ workspaceTransaction: async (_workspace, work) => work(tx) }, { workspaceId: 'workspace', payload: { task_id: 'task' } });
|
||
assert.match(request.messages[0].content, /user_requirements 优先于采集资料/);
|
||
assert.match(request.messages[0].content, /冲突时采用用户修正/);
|
||
assert.match(request.messages[0].content, /用户要求不改变以下输出格式与语言约束/);
|
||
const user = request.messages[1].content;
|
||
assert.ok(user.indexOf('user_requirements') < user.indexOf('辅助商品资料'));
|
||
assert.ok(user.includes(JSON.stringify({ user_requirements: hint })));
|
||
assert.ok(user.includes('蓝色手办包邮 20cm'));
|
||
assert.ok(!user.includes('其他SKU的专属角色'));
|
||
assert.equal((user.match(/user_requirements/g) ?? []).length, 1);
|
||
assert.equal(result.variant_id, 'current');
|
||
assert.deepEqual(result.copy.title, { ru: 'Красная фигурка 25 см', zh: '红色手办 25cm' });
|
||
} finally {
|
||
globalThis.fetch = previousFetch;
|
||
if (previousKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
||
else process.env.DEEPSEEK_API_KEY = previousKey;
|
||
}
|
||
});
|
||
|
||
test('同一商品模型只由类目和稳定商品 ID 确定', () => {
|
||
const category = { name: '手办', path: '玩具 / 手办' };
|
||
const first = makeModel(category, 'abcdef12-0000-4000-8000-000000000000');
|
||
const second = makeModel(category, 'abcdef12-0000-4000-8000-000000000000');
|
||
assert.equal(first, second);
|
||
assert.match(first, /^FIG-MIX-[A-F0-9]{5}$/);
|
||
assert.ok(first.length <= 32);
|
||
assert.equal((first.match(/-/g) ?? []).length, 2);
|
||
});
|
||
|
||
test('文案只保留成对且含俄文字符的字段', () => {
|
||
assert.deepEqual(
|
||
validateCopy({
|
||
title: { ru: 'Фигурка героя', zh: '角色手办' },
|
||
tags: [
|
||
{ ru: 'Коллекционная', zh: '收藏' },
|
||
{ ru: 'invalid', zh: '无效' },
|
||
],
|
||
description: { ru: 'Сделано из золота', zh: '' },
|
||
}),
|
||
{
|
||
title: { ru: 'Фигурка героя', zh: '角色手办' },
|
||
tags: [{ ru: '#Коллекционная', zh: '收藏' }],
|
||
},
|
||
);
|
||
});
|
||
|
||
test('俄文标题、标签和描述中的汉字不会进入回填结果', () => {
|
||
assert.deepEqual(
|
||
validateCopy({
|
||
title: { ru: 'Бейсболка ANTA/安踏', zh: '安踏棒球帽' },
|
||
tags: [
|
||
{ ru: 'Спортивная кепка', zh: '运动帽' },
|
||
{ ru: 'Цвет 运动红-1', zh: '运动红-1' },
|
||
],
|
||
description: {
|
||
ru: 'Бейсболка унисекс. Размер: 均码.',
|
||
zh: '中性棒球帽。尺码:均码。',
|
||
},
|
||
}),
|
||
{ tags: [{ ru: '#Спортивная_кепка', zh: '运动帽' }] },
|
||
);
|
||
});
|
||
|
||
test('单行多句产品描述会保留为易读的段落', () => {
|
||
const copy = validateCopy({
|
||
description: {
|
||
ru: 'Коллекционная фигурка из ПВХ. Размер упаковки: 180 × 120 × 250 мм.',
|
||
zh: 'PVC 收藏手办。包装尺寸为 180 × 120 × 250 mm。',
|
||
},
|
||
});
|
||
assert.equal(
|
||
copy.description?.ru,
|
||
'Коллекционная фигурка из ПВХ.\n\nРазмер упаковки: 180 × 120 × 250 мм.',
|
||
);
|
||
assert.equal(
|
||
copy.description?.zh,
|
||
'PVC 收藏手办。\n\n包装尺寸为 180 × 120 × 250 mm。',
|
||
);
|
||
});
|
||
|
||
test('中文对照中的通用段落标题会规范为中文', () => {
|
||
const copy = validateCopy({
|
||
description: {
|
||
ru: 'Фигурка.\n\nХарактеристики:\n• Материал: ПВХ',
|
||
zh: '收藏手办。\n\nХарактеристики:\n• 材质:PVC',
|
||
},
|
||
});
|
||
assert.match(copy.description?.zh ?? '', /\n\n产品信息:\n• 材质:PVC$/);
|
||
});
|
||
|
||
test('包装建议必须有采集证据,且尺寸重量为有效正数', () => {
|
||
const source = {
|
||
description: '包装尺寸 10 × 20 × 30 mm,包装重量 400 g',
|
||
attributes: [],
|
||
};
|
||
const proposal = {
|
||
length_mm: '10',
|
||
width_mm: '20',
|
||
height_mm: '30',
|
||
shipping_weight_g: '400',
|
||
evidence: '包装尺寸 10 × 20 × 30 mm,包装重量 400 g',
|
||
};
|
||
assert.equal(validatePackage(proposal, source)?.shipping_weight_g, '400');
|
||
assert.equal(
|
||
validatePackage({ ...proposal, evidence: '模型凭空猜测' }, source),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
validatePackage({ ...proposal, shipping_weight_g: '0' }, source),
|
||
null,
|
||
);
|
||
});
|
||
|
||
test('包装字段名称可作为 1688 分离尺寸和重量证据', () => {
|
||
const source = {
|
||
description: null,
|
||
attributes: [
|
||
{ key: 'size', name: '包装尺寸', value: '10 × 20 × 30 mm' },
|
||
{ key: 'weight', name: '包装重量', value: '400 g' },
|
||
],
|
||
};
|
||
const proposal = {
|
||
length_mm: '10',
|
||
width_mm: '20',
|
||
height_mm: '30',
|
||
shipping_weight_g: '400',
|
||
evidence: '10 × 20 × 30 mm\n400 g',
|
||
};
|
||
assert.equal(validatePackage(proposal, source)?.height_mm, '30');
|
||
});
|
||
|
||
test('DeepSeek 结构化请求关闭思考,截断后提高输出上限重试', async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
const originalKey = process.env.DEEPSEEK_API_KEY;
|
||
const requests = [];
|
||
process.env.DEEPSEEK_API_KEY = 'test-key';
|
||
globalThis.fetch = async (_url, options) => {
|
||
requests.push(JSON.parse(options.body));
|
||
const truncated = requests.length === 1;
|
||
return new Response(
|
||
JSON.stringify({
|
||
choices: [
|
||
{
|
||
finish_reason: truncated ? 'length' : 'stop',
|
||
message: { content: truncated ? '' : '{"ok":true}' },
|
||
},
|
||
],
|
||
usage: { total_tokens: 25 },
|
||
}),
|
||
{ status: 200 },
|
||
);
|
||
};
|
||
try {
|
||
assert.deepEqual(await deepSeekJson('输出 JSON', '商品事实'), {
|
||
data: { ok: true },
|
||
tokens: 25,
|
||
});
|
||
assert.equal(requests.length, 2);
|
||
assert.deepEqual(requests[0].thinking, { type: 'disabled' });
|
||
assert.equal(requests[0].max_tokens, 8192);
|
||
assert.equal(requests[1].max_tokens, 16384);
|
||
assert.match(requests[1].messages[1].content, /不要只返回空白/);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
if (originalKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
||
else process.env.DEEPSEEK_API_KEY = originalKey;
|
||
}
|
||
});
|
||
|
||
test('DeepSeek JSON 模式返回空白内容时调整提示词重试', async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
const originalKey = process.env.DEEPSEEK_API_KEY;
|
||
const requests = [];
|
||
process.env.DEEPSEEK_API_KEY = 'test-key';
|
||
globalThis.fetch = async (_url, options) => {
|
||
requests.push(JSON.parse(options.body));
|
||
return new Response(
|
||
JSON.stringify({
|
||
choices: [{
|
||
finish_reason: 'stop',
|
||
message: { content: requests.length === 1 ? ' ' : '{"ok":true}' },
|
||
}],
|
||
}),
|
||
{ status: 200 },
|
||
);
|
||
};
|
||
try {
|
||
assert.deepEqual((await deepSeekJson('输出 JSON', '商品事实')).data, { ok: true });
|
||
assert.equal(requests.length, 2);
|
||
assert.equal(requests[1].max_tokens, 8192);
|
||
assert.match(requests[1].messages[1].content, /不要只返回空白/);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
if (originalKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
||
else process.env.DEEPSEEK_API_KEY = originalKey;
|
||
}
|
||
});
|
||
|
||
|
||
test('AI 标签补齐井号、整理词间空格和连字符,过滤非法及超长项并保留中文配对', () => {
|
||
const copy = validateCopy({ tags: [
|
||
{ ru: 'Спортивный стиль', zh: '运动风格' },
|
||
{ ru: '#Чёрно-белый', zh: '黑白' },
|
||
{ ru: '#Спортивный_стиль', zh: '重复主题' },
|
||
{ ru: '#стиль/мода', zh: '非法标点' },
|
||
{ ru: '#стиль#мода', zh: '缺少分隔' },
|
||
{ ru: '#стиль😀', zh: '表情' },
|
||
{ ru: '#' + 'я'.repeat(30), zh: '超长' },
|
||
{ ru: '#' + 'я'.repeat(29), zh: '长度边界' },
|
||
] });
|
||
assert.deepEqual(copy.tags, [
|
||
{ ru: '#Спортивный_стиль', zh: '运动风格' },
|
||
{ ru: '#Чёрно_белый', zh: '黑白' },
|
||
{ ru: '#' + 'я'.repeat(29), zh: '长度边界' },
|
||
]);
|
||
const many = validateCopy({tags:Array.from({length:35},(_,i)=>({ru:`#стиль${i}`,zh:`风格${i}`}))});
|
||
assert.equal(many.tags.length,30);
|
||
});
|
||
|
||
test('全局 AI 填报和 SKU 文案都要求标签格式并在返回前整理输出', async () => {
|
||
const previousFetch = globalThis.fetch;
|
||
const previousKey = process.env.DEEPSEEK_API_KEY;
|
||
process.env.DEEPSEEK_API_KEY = 'test-key';
|
||
const prompts = [];
|
||
globalThis.fetch = async (_url, options) => {
|
||
const request = JSON.parse(options.body);
|
||
prompts.push(request.messages[0].content);
|
||
return new Response(JSON.stringify({ choices: [{ finish_reason: 'stop', message: {
|
||
content: JSON.stringify({ tags: [{ru:'Спортивный стиль',zh:'运动风格'}] }),
|
||
}}] }), {status:200});
|
||
};
|
||
try {
|
||
for (const kind of ['autofill','sku_copy']) {
|
||
let saved;
|
||
const input = {
|
||
platform_id:'platform',product_id:'product',profile_id:'profile',variant_id:'sku',
|
||
source_material:{title:'帽子',description:null,attributes:[],categories:[]},
|
||
common:{model:'model'},profile:{category:{id:'category'},tags:[],target_store_id:'store'},
|
||
variants:[{id:'sku',listing:{status:'imported',offer_id:'offer'},specification_values:[]}],
|
||
};
|
||
const tx = { query: async (sql, values) => {
|
||
if(sql.includes('SELECT kind, status')) return {rows:[{kind,status:'queued',input_snapshot:input}]};
|
||
if(sql.includes('FROM atlas.platform_product_categories')) return {rows:[{id:'category',name:'帽子',path:'帽子',is_active:true,external_type_id:'1'}]};
|
||
if(sql.includes('FROM atlas.product_variant_platform_listings')) return {rows:[]};
|
||
if(sql.includes('result = $3::jsonb')) saved=JSON.parse(values[2]);
|
||
return {rows:[]};
|
||
}};
|
||
await runProductAiTask({workspaceTransaction:async (_workspace,work)=>work(tx)}, {
|
||
workspaceId:'workspace',payload:{task_id:'task'},
|
||
});
|
||
assert.deepEqual((kind==='autofill'?saved.spu_copy:saved.copy).tags,[{ru:'#Спортивный_стиль',zh:'运动风格'}]);
|
||
}
|
||
assert.equal(prompts.length,2);
|
||
for(const prompt of prompts) {
|
||
assert.match(prompt,/每个 tags\[\]\.ru 仅包含一个标签/);
|
||
assert.match(prompt,/最多 30 个字符/);
|
||
assert.match(prompt,/#спортивный_стиль/);
|
||
}
|
||
} finally {
|
||
globalThis.fetch=previousFetch;
|
||
if(previousKey===undefined) delete process.env.DEEPSEEK_API_KEY;
|
||
else process.env.DEEPSEEK_API_KEY=previousKey;
|
||
}
|
||
});
|