import assert from 'node:assert/strict'; import { test } from 'node:test'; import { deepSeekJson, makeModel, validateCopy, validatePackage } 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; } });