Files
atlas-seller-kit/apps/worker/test/product-ai.test.mjs
T

250 lines
10 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.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { deepSeekJson, makeModel, validateCopy, validatePackage, runProductAiTask } from '../src/product-ai.ts';
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;
}
});