forked from lunanting/cangku
956 lines
45 KiB
JavaScript
956 lines
45 KiB
JavaScript
'use strict';
|
||
|
||
const axios = require('axios');
|
||
const database = require('../config/database');
|
||
|
||
const CONFIG_KEY = 'selection_fission_ai_analysis_config';
|
||
const DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||
const DEFAULT_MODEL = 'qwen3.7-max-2026-06-08';
|
||
const DEFAULT_PDD_PROPERTY_MODEL = 'qwen3.8-max';
|
||
const DEFAULT_TIMEOUT_MS = 120000;
|
||
const DEFAULT_IMAGE_GENERATION_TIMEOUT_MS = 15 * 60 * 1000;
|
||
const DEFAULT_EXTERNAL_IMAGE_BASE_URL = 'https://ai.yydd.ai';
|
||
const DEFAULT_EXTERNAL_IMAGE_MODEL = 'provider-default';
|
||
const DEFAULT_QWEN_IMAGE_BASE_URL = 'https://dashscope.aliyuncs.com';
|
||
const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-3.0-pro';
|
||
const MAX_SKUS_PER_REQUEST = 8;
|
||
const REQUIRED_NAME_COUNT = 5;
|
||
const MAX_SKU_NAME_ATTEMPTS = 3;
|
||
const MIN_PRODUCT_TITLE_LENGTH = 28;
|
||
const MAX_PRODUCT_TITLE_LENGTH = 30;
|
||
|
||
const DEFAULT_SKU_NAME_PROMPT = [
|
||
'你是中国电商平台的SKU命名专家。请基于公司原始SKU名称、规格、颜色、SPU和市场商品信息,为每个SKU生成5个新的中文SKU名称。',
|
||
'名称需要准确区分规格和颜色,保留影响用户选择的核心属性,表达自然简洁,不得虚构材质、功能、品牌、尺寸或赠品。',
|
||
'尺寸和规格以公司原始SKU名称为最高优先级;接口中的独立size字段可能是物流包装尺寸,与原始名称冲突时必须忽略。',
|
||
'每个名称不超过60个汉字,不要带序号,5个名称互不重复,也不得与原始SKU名称完全相同。',
|
||
'只输出JSON对象,格式为:{"items":[{"skuCode":"SKU编码","names":["名称1","名称2","名称3","名称4","名称5"]}]}。',
|
||
].join('\n');
|
||
|
||
const DEFAULT_PRODUCT_TITLE_PROMPT = [
|
||
'你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,先推荐分类关键词,再生成5个中文商品标题。',
|
||
'关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。',
|
||
'竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。',
|
||
'组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。',
|
||
'参考链接标题只用于提取通用品类、材质、功能、场景和搜索词。必须识别其中的第三方品牌、系列名和品牌型号,写入forbiddenBrandTerms,并确保所有候选标题完全不包含这些词。',
|
||
'每个标题必须为28至30个汉字,在不重复堆砌的前提下优先写满30字;不要空格、标点或序号。不得虚构或复制第三方品牌、材质、功能、尺寸、赠品或认证。',
|
||
'只输出JSON对象,格式为:{"keywordStrategy":{"brand":"","category":"品类词","material":"材质词","functionalSellingPoints":["功能词"],"scenes":["场景词"],"hotKeywords":["热搜词"],"surgingKeywords":["飙升词"],"modifiers":["修饰词"],"forbiddenBrandTerms":["参考标题中的第三方品牌或型号"]},"titles":["标题1","标题2","标题3","标题4","标题5"]}。',
|
||
].join('\n');
|
||
const PREVIOUS_PRODUCT_TITLE_PROMPT = [
|
||
'你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,生成5个中文商品标题。',
|
||
'关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。',
|
||
'竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。',
|
||
'组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。',
|
||
'每个标题最多30个字符,不要空格、标点、序号或重复堆砌。不得虚构品牌、材质、功能、尺寸、赠品或认证。',
|
||
'只输出JSON对象,格式为:{"titles":["标题1","标题2","标题3","标题4","标题5"]}。',
|
||
].join('\n');
|
||
const RECENT_PRODUCT_TITLE_PROMPT = [
|
||
'你是中国电商平台的商品标题策略专家。请根据平台标题规则、结构化关键词、竞品标题和公司商品真实信息,先推荐分类关键词,再生成5个中文商品标题。',
|
||
'关键词结构为:品牌(可选)、品类(必填)、材质(可选)、功能卖点(必填)、场景(必填)、热搜词(必填)、飙升词(必填)、修饰词(可选)。',
|
||
'竞品关键词优先从选中的榜单Top20商品标题拆分;品类关键词从系统提供的10个品类词中选择。合并后按出现频次和搜索意图确定优先级。',
|
||
'组题时删除与前置品牌、品类、材质、功能词语义重复的词;所有飙升词必须出现,品类词必须出现。',
|
||
'每个标题必须为26至30个汉字,在不重复堆砌的前提下尽量接近30字;不要空格、标点或序号。不得虚构品牌、材质、功能、尺寸、赠品或认证。',
|
||
'只输出JSON对象,格式为:{"keywordStrategy":{"brand":"","category":"品类词","material":"材质词","functionalSellingPoints":["功能词"],"scenes":["场景词"],"hotKeywords":["热搜词"],"surgingKeywords":["飙升词"],"modifiers":["修饰词"]},"titles":["标题1","标题2","标题3","标题4","标题5"]}。',
|
||
].join('\n');
|
||
const LEGACY_PRODUCT_TITLE_PROMPT = [
|
||
'你是中国电商平台的商品标题优化专家。请根据市场商品标题、公司SPU、类目和商品图片生成5个新的中文商品标题。',
|
||
'标题需要覆盖核心品类词、主要规格、颜色和真实卖点,表达自然并适合电商搜索,不得虚构品牌、材质、功能、尺寸、赠品或认证。',
|
||
'每个标题不超过60个汉字,不要带序号,不要重复。',
|
||
'只输出JSON对象,格式为:{"titles":["标题1","标题2","标题3","标题4","标题5"]}。',
|
||
].join('\n');
|
||
|
||
const LEGACY_MAIN_IMAGE_PROMPT = [
|
||
'第一张图是市场参考图,第二张图是公司商品白底图。',
|
||
'彻底移除市场参考图中的原商品、文字、图标、标签、角标、价格、水印和品牌标识,只保留干净的场景、构图、视角、光线与真实阴影。',
|
||
'将第二张图中的公司商品完整替换到市场参考图原商品位置,保持商品结构、颜色和比例准确,边缘自然,不新增任何文字或图标。',
|
||
'参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。',
|
||
'所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。',
|
||
'装饰品仅作为场景陈设,不得改变、遮挡或虚构商品的层板、柜门、五金、尺寸和其他主体结构,也不得表现为随商品赠送的配件。',
|
||
'输出一张可直接用于电商主图的正方形高清图片。',
|
||
].join('\n');
|
||
|
||
const PREVIOUS_MAIN_IMAGE_PROMPT = [
|
||
'第一张图是公司商品白底图或干净商品图,第二张图是同品类市场参考图。第一张图决定商品身份,第二张图决定商业摄影构图与视觉风格。',
|
||
'第一张图是唯一商品主体,禁止保留、复刻或混合市场商品主体。',
|
||
'必须准确保留公司商品的外形、结构、颜色、材质细节、比例、接口、层数、配件和数量;如果公司图带原始场景,只清理背景,不得改变商品本体。',
|
||
'强参考市场图的机位、视角、裁切、主体占比、空间布局、陈列方式、景深、灯光方向、色温、背景材质和接触阴影,把公司商品放入市场图原商品的位置。',
|
||
'彻底移除市场图中的原商品、文字、图标、标签、角标、价格、水印和品牌标识,但保留其场景道具与氛围。最终效果是公司商品主体加市场图的构图风格,不要生成白底棚拍或泛化背景。',
|
||
'参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。',
|
||
'所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。',
|
||
'装饰品仅作为场景陈设,不得改变、遮挡或虚构商品的层板、柜门、五金、尺寸和其他主体结构,也不得表现为随商品赠送的配件。',
|
||
'输出一张可直接用于电商主图的正方形高清图片。',
|
||
].join('\n');
|
||
|
||
const DEFAULT_MAIN_IMAGE_PROMPT = [
|
||
'第一张图是目标商品图,也是唯一商品身份来源;第二张图是同品类市场参考图,只提供商业摄影场景、机位、构图、裁切、灯光、阴影和道具。',
|
||
'彻底移除第二张图中的市场商品及其品牌信息,把第一张图的完整目标商品作为一个刚性整体放入相同场景,禁止拆分重绘或复制市场商品结构。',
|
||
'目标商品的轮廓、几何结构、长宽高比例、厚度、开口、边缘、连接关系、层数、部件位置与精确数量、颜色、材质、纹理、五金和配件必须与第一张图完全一致。',
|
||
'若市场图的机位会迫使商品变形,优先保持目标商品外观结构不变,只采用最接近的有效机位。',
|
||
'参考市场图的陈设风格,在商品上或周围布置与品类和场景协调的书籍、摆件、收纳盒、绿植等生活化装饰品,画面饱满但不过度堆砌,不能输出光秃秃的裸产品。',
|
||
'所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。',
|
||
'装饰品仅作为场景陈设,不得改变、遮挡或虚构商品结构,不得表现为随商品赠送的配件。输出前逐项核对腿、抽屉、层板、柜门、面板、横梁、把手、孔位、轮子、接口和附件。',
|
||
'输出一张可直接用于电商主图的正方形高清图片。',
|
||
].join('\n');
|
||
|
||
const LEGACY_SKU_IMAGE_PROMPT = [
|
||
'第一张图是已完成产品替换的市场主图场景,第二张图是公司SKU产品图。',
|
||
'把第二张图的SKU商品自然放入第一张图相同的场景、构图、视角和光线中,准确保留SKU结构、颜色、尺寸比例与细节。',
|
||
'沿用第一张图的装饰风格,在SKU商品上或周围合理布置书籍、摆件、收纳盒、绿植等生活化场景道具;如果参考图装饰不足,需要主动补充,不能输出光秃秃的裸产品。',
|
||
'所有装饰品必须采用无文字、无Logo设计,书籍封面和书脊只保留纯色或自然纹理,不得出现任何字符、符号或伪文字。',
|
||
'装饰品不能遮挡或改变SKU主体结构,不得虚构商品组件或暗示装饰品随商品赠送;不新增文字、图标、水印或品牌标识。',
|
||
'输出一张正方形高清电商SKU图。',
|
||
].join('\n');
|
||
|
||
const PREVIOUS_SKU_IMAGE_PROMPT = [
|
||
'第一张图是当前SKU唯一商品身份图,必须准确保留该SKU的结构、规格、颜色、材质、尺寸比例、部件和配件数量。',
|
||
'只有在参考市场图生成模式下才会提供第二张图;第二张只提供场景、构图、机位、裁切和光线参考,必须完全移除其原商品。',
|
||
'禁止引用主图商品、其他SKU或参考图原商品,禁止修改当前SKU的规格、颜色、结构和配件。',
|
||
'清除文字图标模式下只移除文字、品牌、店铺标识、价格、促销标签、角标、水印、二维码和图标覆盖物,不得重绘商品。',
|
||
'输出一张且仅一张无文字、无Logo、无水印的高清电商SKU图。',
|
||
].join('\n');
|
||
|
||
const DEFAULT_SKU_IMAGE_PROMPT = [
|
||
'第一张图是当前SKU唯一商品身份图;必须把该SKU作为不可拆分、不可重新设计的刚性整体,禁止引用主图商品、同SPU其他SKU或市场参考图商品。',
|
||
'只有在参考市场图生成模式下才会提供第二张图;第二张只提供场景、构图、机位、裁切、灯光、阴影和道具,必须彻底移除其原商品及品牌信息。',
|
||
'当前SKU的轮廓、几何结构、规格、长宽高比例、厚度、开口、连接关系、部件位置与精确数量、颜色、材质、纹理、五金和配件必须与第一张图完全一致。',
|
||
'若市场图机位会导致SKU变形或零部件变化,优先保持SKU外观结构不变,只采用最接近的有效机位;输出前逐项核对所有可见部件和附件。',
|
||
'清除文字图标模式下只移除文字、品牌、店铺标识、价格、促销标签、角标、水印、二维码和图标覆盖物,不得重绘商品。',
|
||
'输出一张且仅一张无文字、无Logo、无水印的高清电商SKU图。',
|
||
].join('\n');
|
||
|
||
function normalizeText(value, maxLength = 1000) {
|
||
return String(value ?? '').trim().slice(0, maxLength);
|
||
}
|
||
|
||
function normalizeBoolean(value, fallback = false) {
|
||
if (value === true || value === false) return value;
|
||
const text = String(value ?? '').trim().toLowerCase();
|
||
if (['1', 'true', 'yes', 'on', 'enabled'].includes(text)) return true;
|
||
if (['0', 'false', 'no', 'off', 'disabled'].includes(text)) return false;
|
||
return fallback;
|
||
}
|
||
|
||
function normalizePositiveInteger(value, fallback, min = 1000, max = 10 * 60 * 1000) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return fallback;
|
||
return Math.min(Math.max(Math.trunc(number), min), max);
|
||
}
|
||
|
||
function normalizeBaseUrl(value) {
|
||
return normalizeText(value, 500).replace(/\/+$/, '');
|
||
}
|
||
|
||
function getFirstEnvValue(names = [], fallback = '') {
|
||
for (const name of names) {
|
||
const value = normalizeText(process.env[name], 4000);
|
||
if (value) return value;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function preserveSecret(input, previous, fallback = '') {
|
||
return input === undefined || input === ''
|
||
? normalizeText(previous || fallback, 4000)
|
||
: normalizeText(input, 4000);
|
||
}
|
||
|
||
function normalizeImageSize() {
|
||
return '1K';
|
||
}
|
||
|
||
function normalizeImageGenerationConfig(value, existing = {}) {
|
||
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||
const previous = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
||
const rawExternal = raw.external && typeof raw.external === 'object' ? raw.external : {};
|
||
const previousExternal = previous.external && typeof previous.external === 'object' ? previous.external : {};
|
||
const rawQwen = raw.qwen && typeof raw.qwen === 'object' ? raw.qwen : {};
|
||
const previousQwen = previous.qwen && typeof previous.qwen === 'object' ? previous.qwen : {};
|
||
const provider = normalizeText(raw.provider || previous.provider, 40) === 'qwen'
|
||
? 'qwen'
|
||
: 'external';
|
||
|
||
return {
|
||
provider,
|
||
timeoutMs: normalizePositiveInteger(
|
||
raw.timeoutMs || raw.timeout_ms || previous.timeoutMs || previous.timeout_ms,
|
||
normalizePositiveInteger(
|
||
process.env.SELECTION_IMAGE_GEN_TIMEOUT_MS,
|
||
DEFAULT_IMAGE_GENERATION_TIMEOUT_MS,
|
||
10000,
|
||
30 * 60 * 1000
|
||
),
|
||
10000,
|
||
30 * 60 * 1000
|
||
),
|
||
external: {
|
||
baseUrl: normalizeBaseUrl(
|
||
rawExternal.baseUrl
|
||
|| rawExternal.base_url
|
||
|| previousExternal.baseUrl
|
||
|| previousExternal.base_url
|
||
|| getFirstEnvValue(['SELECTION_IMAGE_GEN_BASE_URL', 'IMAGE_GEN_BASE_URL'], DEFAULT_EXTERNAL_IMAGE_BASE_URL)
|
||
) || DEFAULT_EXTERNAL_IMAGE_BASE_URL,
|
||
apiKey: preserveSecret(
|
||
rawExternal.apiKey ?? rawExternal.api_key,
|
||
previousExternal.apiKey || previousExternal.api_key,
|
||
getFirstEnvValue(['SELECTION_IMAGE_GEN_API_KEY', 'IMAGE_GEN_API_KEY', 'EXTERNAL_IMAGE_GEN_API_KEY'])
|
||
),
|
||
model: normalizeText(
|
||
rawExternal.model || previousExternal.model || process.env.SELECTION_IMAGE_GEN_MODEL || DEFAULT_EXTERNAL_IMAGE_MODEL,
|
||
160
|
||
) || DEFAULT_EXTERNAL_IMAGE_MODEL,
|
||
imageSize: normalizeImageSize(
|
||
rawExternal.imageSize
|
||
|| rawExternal.image_size
|
||
|| previousExternal.imageSize
|
||
|| previousExternal.image_size
|
||
|| process.env.SELECTION_IMAGE_GEN_IMAGE_SIZE,
|
||
'1K'
|
||
),
|
||
},
|
||
qwen: {
|
||
baseUrl: normalizeBaseUrl(
|
||
rawQwen.baseUrl
|
||
|| rawQwen.base_url
|
||
|| previousQwen.baseUrl
|
||
|| previousQwen.base_url
|
||
|| process.env.QWEN_IMAGE_BASE_URL
|
||
|| DEFAULT_QWEN_IMAGE_BASE_URL
|
||
) || DEFAULT_QWEN_IMAGE_BASE_URL,
|
||
apiKey: preserveSecret(
|
||
rawQwen.apiKey ?? rawQwen.api_key,
|
||
previousQwen.apiKey || previousQwen.api_key,
|
||
process.env.QWEN_IMAGE_API_KEY
|
||
),
|
||
model: normalizeText(
|
||
rawQwen.model || previousQwen.model || process.env.QWEN_IMAGE_MODEL || DEFAULT_QWEN_IMAGE_MODEL,
|
||
160
|
||
) || DEFAULT_QWEN_IMAGE_MODEL,
|
||
imageSize: normalizeImageSize(
|
||
rawQwen.imageSize || rawQwen.image_size || previousQwen.imageSize || previousQwen.image_size,
|
||
'1K'
|
||
),
|
||
promptExtend: normalizeBoolean(
|
||
rawQwen.promptExtend ?? rawQwen.prompt_extend,
|
||
normalizeBoolean(previousQwen.promptExtend ?? previousQwen.prompt_extend, true)
|
||
),
|
||
watermark: normalizeBoolean(
|
||
rawQwen.watermark,
|
||
normalizeBoolean(previousQwen.watermark, false)
|
||
),
|
||
},
|
||
};
|
||
}
|
||
|
||
function maskSecret(value) {
|
||
const text = String(value || '');
|
||
if (!text) return '';
|
||
if (text.length <= 8) return `${text.slice(0, 2)}******`;
|
||
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
|
||
}
|
||
|
||
function safeJsonParse(value, fallback = null) {
|
||
if (!value) return fallback;
|
||
if (typeof value === 'object') return value;
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch (_error) {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function normalizeMainImagePrompt(value) {
|
||
const prompt = normalizeText(value, 8000);
|
||
const isPreviousDefaultPrompt = prompt.startsWith('第一张图是公司商品白底图,第二张图是市场参考图。');
|
||
return !prompt
|
||
|| prompt === LEGACY_MAIN_IMAGE_PROMPT
|
||
|| prompt === PREVIOUS_MAIN_IMAGE_PROMPT
|
||
|| isPreviousDefaultPrompt
|
||
? DEFAULT_MAIN_IMAGE_PROMPT
|
||
: prompt;
|
||
}
|
||
|
||
function normalizeSkuImagePrompt(value) {
|
||
const prompt = normalizeText(value, 8000);
|
||
const isPreviousDefaultPrompt = prompt.startsWith('参考已替换完成的市场主图场景');
|
||
return !prompt
|
||
|| prompt === LEGACY_SKU_IMAGE_PROMPT
|
||
|| prompt === PREVIOUS_SKU_IMAGE_PROMPT
|
||
|| isPreviousDefaultPrompt
|
||
? DEFAULT_SKU_IMAGE_PROMPT
|
||
: prompt;
|
||
}
|
||
|
||
async function getSystemConfigValue() {
|
||
const rows = await database.local.query(
|
||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||
[CONFIG_KEY]
|
||
);
|
||
return rows[0]?.configValue ?? null;
|
||
}
|
||
|
||
async function upsertSystemConfig(value) {
|
||
const configValue = JSON.stringify(value ?? null);
|
||
const rows = await database.local.query(
|
||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||
[CONFIG_KEY]
|
||
);
|
||
if (rows[0]) {
|
||
await database.local.query(
|
||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||
[configValue, 'json', CONFIG_KEY]
|
||
);
|
||
return;
|
||
}
|
||
await database.local.insert('system_config', {
|
||
config_key: CONFIG_KEY,
|
||
config_value: configValue,
|
||
config_type: 'json',
|
||
});
|
||
}
|
||
|
||
function normalizeConfig(value, existing = {}) {
|
||
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||
const previous = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
||
const apiKeyInput = raw.apiKey ?? raw.api_key;
|
||
const apiKey = apiKeyInput === undefined || apiKeyInput === ''
|
||
? normalizeText(previous.apiKey || previous.api_key || process.env.DASHSCOPE_API_KEY, 4000)
|
||
: normalizeText(apiKeyInput, 4000);
|
||
|
||
const requestedProductTitlePrompt = normalizeText(
|
||
raw.productTitlePrompt
|
||
|| raw.product_title_prompt
|
||
|| previous.productTitlePrompt
|
||
|| previous.product_title_prompt,
|
||
8000
|
||
);
|
||
const requestedMainImagePrompt = normalizeText(
|
||
raw.mainImagePrompt
|
||
|| raw.main_image_prompt
|
||
|| previous.mainImagePrompt
|
||
|| previous.main_image_prompt,
|
||
8000
|
||
);
|
||
const imageGenerationInput = raw.imageGeneration || raw.image_generation;
|
||
const previousImageGeneration = previous.imageGeneration || previous.image_generation;
|
||
return {
|
||
enabled: normalizeBoolean(raw.enabled, normalizeBoolean(previous.enabled, true)),
|
||
provider: 'aliyun-model-studio',
|
||
baseUrl: normalizeBaseUrl(
|
||
raw.baseUrl || raw.base_url || previous.baseUrl || previous.base_url || DEFAULT_BASE_URL
|
||
) || DEFAULT_BASE_URL,
|
||
apiKey,
|
||
model: normalizeText(raw.model || previous.model || DEFAULT_MODEL, 160) || DEFAULT_MODEL,
|
||
skuNamePrompt: normalizeText(
|
||
raw.skuNamePrompt || raw.sku_name_prompt || previous.skuNamePrompt || previous.sku_name_prompt || DEFAULT_SKU_NAME_PROMPT,
|
||
8000
|
||
) || DEFAULT_SKU_NAME_PROMPT,
|
||
productTitlePrompt: !requestedProductTitlePrompt
|
||
|| requestedProductTitlePrompt === LEGACY_PRODUCT_TITLE_PROMPT
|
||
|| requestedProductTitlePrompt === PREVIOUS_PRODUCT_TITLE_PROMPT
|
||
|| requestedProductTitlePrompt === RECENT_PRODUCT_TITLE_PROMPT
|
||
? DEFAULT_PRODUCT_TITLE_PROMPT
|
||
: requestedProductTitlePrompt,
|
||
mainImagePrompt: normalizeMainImagePrompt(requestedMainImagePrompt),
|
||
skuImagePrompt: normalizeSkuImagePrompt(
|
||
raw.skuImagePrompt
|
||
|| raw.sku_image_prompt
|
||
|| previous.skuImagePrompt
|
||
|| previous.sku_image_prompt
|
||
|| DEFAULT_SKU_IMAGE_PROMPT,
|
||
),
|
||
timeoutMs: normalizePositiveInteger(
|
||
raw.timeoutMs || raw.timeout_ms || previous.timeoutMs || previous.timeout_ms,
|
||
DEFAULT_TIMEOUT_MS,
|
||
10000,
|
||
10 * 60 * 1000
|
||
),
|
||
imageGeneration: normalizeImageGenerationConfig(
|
||
imageGenerationInput,
|
||
previousImageGeneration
|
||
),
|
||
};
|
||
}
|
||
|
||
function toAdminConfig(config) {
|
||
const imageGeneration = config.imageGeneration || normalizeImageGenerationConfig();
|
||
return {
|
||
enabled: Boolean(config.enabled),
|
||
provider: config.provider,
|
||
baseUrl: config.baseUrl,
|
||
model: config.model,
|
||
skuNamePrompt: config.skuNamePrompt,
|
||
productTitlePrompt: config.productTitlePrompt,
|
||
mainImagePrompt: config.mainImagePrompt,
|
||
skuImagePrompt: config.skuImagePrompt,
|
||
timeoutMs: config.timeoutMs,
|
||
apiKeyConfigured: Boolean(config.apiKey),
|
||
apiKeyPreview: maskSecret(config.apiKey),
|
||
imageGeneration: {
|
||
provider: imageGeneration.provider,
|
||
timeoutMs: imageGeneration.timeoutMs,
|
||
external: {
|
||
baseUrl: imageGeneration.external.baseUrl,
|
||
model: imageGeneration.external.model,
|
||
imageSize: imageGeneration.external.imageSize,
|
||
apiKeyConfigured: Boolean(imageGeneration.external.apiKey),
|
||
apiKeyPreview: maskSecret(imageGeneration.external.apiKey),
|
||
},
|
||
qwen: {
|
||
baseUrl: imageGeneration.qwen.baseUrl,
|
||
model: imageGeneration.qwen.model,
|
||
imageSize: imageGeneration.qwen.imageSize,
|
||
promptExtend: imageGeneration.qwen.promptExtend,
|
||
watermark: imageGeneration.qwen.watermark,
|
||
apiKeyConfigured: Boolean(imageGeneration.qwen.apiKey),
|
||
apiKeyPreview: maskSecret(imageGeneration.qwen.apiKey),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
async function getSelectionFissionAiConfig() {
|
||
const raw = await getSystemConfigValue().catch(() => null);
|
||
return normalizeConfig(safeJsonParse(raw, {}));
|
||
}
|
||
|
||
async function getSelectionFissionAiAdminSettings() {
|
||
return toAdminConfig(await getSelectionFissionAiConfig());
|
||
}
|
||
|
||
async function getSelectionFissionImageGenerationConfig() {
|
||
const config = await getSelectionFissionAiConfig();
|
||
return config.imageGeneration;
|
||
}
|
||
|
||
async function saveSelectionFissionAiConfig(input = {}) {
|
||
const raw = await getSystemConfigValue().catch(() => null);
|
||
const existing = safeJsonParse(raw, {});
|
||
const config = normalizeConfig(input, existing);
|
||
await upsertSystemConfig(config);
|
||
return toAdminConfig(config);
|
||
}
|
||
|
||
function parseFirstJsonObject(text) {
|
||
const raw = String(text || '').trim();
|
||
if (!raw) return null;
|
||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||
for (const candidate of [fenced?.[1], raw].filter(Boolean)) {
|
||
try {
|
||
return JSON.parse(candidate);
|
||
} catch (_error) {
|
||
const start = candidate.indexOf('{');
|
||
const end = candidate.lastIndexOf('}');
|
||
if (start >= 0 && end > start) {
|
||
try {
|
||
return JSON.parse(candidate.slice(start, end + 1));
|
||
} catch (_nestedError) {
|
||
// Try the next candidate.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeGeneratedName(value) {
|
||
return normalizeText(value, 120)
|
||
.replace(/^[\d一二三四五]+[.、::)\]]\s*/, '')
|
||
.replace(/^["'“”]|["'“”]$/g, '')
|
||
.trim();
|
||
}
|
||
|
||
function normalizeSkuNameCandidatesPayload(payload, skuRows = []) {
|
||
const rawItems = Array.isArray(payload)
|
||
? payload
|
||
: Array.isArray(payload?.items)
|
||
? payload.items
|
||
: [];
|
||
const itemBySku = new Map();
|
||
for (const item of rawItems) {
|
||
const skuCode = normalizeText(item?.skuCode || item?.sku_code || item?.sku, 160);
|
||
const names = Array.isArray(item?.names)
|
||
? item.names
|
||
: Array.isArray(item?.skuNames)
|
||
? item.skuNames
|
||
: Array.isArray(item?.sku_names)
|
||
? item.sku_names
|
||
: [];
|
||
const uniqueNames = Array.from(new Set(names.map(normalizeGeneratedName).filter(Boolean)));
|
||
if (skuCode) itemBySku.set(skuCode.toUpperCase(), uniqueNames);
|
||
}
|
||
|
||
return skuRows.map((row) => {
|
||
const skuCode = normalizeText(row.skuCode || row.sku_code, 160);
|
||
const originalSkuName = normalizeGeneratedName(
|
||
row.originalSkuName || row.original_sku_name || row.skuName || row.sku_name
|
||
);
|
||
const names = (itemBySku.get(skuCode.toUpperCase()) || [])
|
||
.filter((name) => !originalSkuName || name !== originalSkuName);
|
||
if (names.length !== REQUIRED_NAME_COUNT) {
|
||
const error = new Error(
|
||
`AI未能为SKU ${skuCode} 返回恰好${REQUIRED_NAME_COUNT}个与原名不同的有效名称`
|
||
);
|
||
error.status = 502;
|
||
error.code = 'AI_SKU_NAME_INVALID_RESPONSE';
|
||
throw error;
|
||
}
|
||
return { skuCode, originalSkuName, names };
|
||
});
|
||
}
|
||
|
||
function buildSkuNameRequestContent(context, skuRows, prompt) {
|
||
const rowsForPrompt = skuRows.map((row) => ({
|
||
skuCode: row.skuCode,
|
||
originalSkuName: row.originalSkuName || row.skuName,
|
||
size: row.size || '',
|
||
color: row.colorName || '',
|
||
}));
|
||
const content = [{
|
||
type: 'text',
|
||
text: [
|
||
prompt,
|
||
'',
|
||
`SPU编码:${normalizeText(context.spuCode, 160) || '未提供'}`,
|
||
`SPU名称:${normalizeText(context.spuName, 255) || '未提供'}`,
|
||
`市场商品标题:${normalizeText(context.marketTitle, 500) || '未提供'}`,
|
||
`待处理SKU:${JSON.stringify(rowsForPrompt)}`,
|
||
].join('\n'),
|
||
}];
|
||
for (const row of skuRows) {
|
||
const imageUrl = normalizeText(row.imageUrl || row.generatedImageUrl || row.skuImageUrl, 2000);
|
||
if (imageUrl) {
|
||
content.push({ type: 'text', text: `SKU ${row.skuCode} 参考图片:` });
|
||
content.push({ type: 'image_url', image_url: { url: imageUrl } });
|
||
}
|
||
}
|
||
return content;
|
||
}
|
||
|
||
function buildSkuNameRepairInstruction(skuRows = [], attempt = 1) {
|
||
return [
|
||
`上一次输出未通过校验,这是第${attempt + 1}次生成。`,
|
||
`必须为下列每个SKU分别返回恰好${REQUIRED_NAME_COUNT}个名称,不能遗漏任何SKU:${skuRows.map((row) => row.skuCode).join('、')}。`,
|
||
'同一SKU的名称必须互不重复,且不能与originalSkuName完全相同;只返回约定的JSON对象,不要解释。',
|
||
].join('\n');
|
||
}
|
||
|
||
async function requestChatCompletion(config, messages, maxCompletionTokens = 1800) {
|
||
if (!config.enabled) {
|
||
const error = new Error('选品AI分析模型未启用');
|
||
error.status = 409;
|
||
error.code = 'SELECTION_AI_DISABLED';
|
||
throw error;
|
||
}
|
||
if (!config.apiKey) {
|
||
const error = new Error('选品AI分析模型尚未配置API Key');
|
||
error.status = 409;
|
||
error.code = 'SELECTION_AI_API_KEY_MISSING';
|
||
throw error;
|
||
}
|
||
|
||
try {
|
||
const response = await axios.post(`${config.baseUrl}/chat/completions`, {
|
||
model: config.model,
|
||
messages,
|
||
response_format: { type: 'json_object' },
|
||
max_completion_tokens: maxCompletionTokens,
|
||
enable_thinking: false,
|
||
temperature: 0.7,
|
||
}, {
|
||
timeout: config.timeoutMs,
|
||
headers: {
|
||
Authorization: `Bearer ${config.apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
maxBodyLength: 20 * 1024 * 1024,
|
||
});
|
||
return response.data;
|
||
} catch (error) {
|
||
const message = normalizeText(
|
||
error.response?.data?.error?.message || error.response?.data?.message || error.message,
|
||
1000
|
||
);
|
||
const wrapped = new Error(`阿里云模型请求失败:${message || '未知错误'}`);
|
||
wrapped.status = error.response?.status || 502;
|
||
wrapped.code = error.response?.data?.error?.code || 'ALIYUN_MODEL_REQUEST_FAILED';
|
||
throw wrapped;
|
||
}
|
||
}
|
||
|
||
function normalizePddPropertyRecommendations(payload, categoryRule = {}) {
|
||
const inputRows = Array.isArray(payload?.properties)
|
||
? payload.properties
|
||
: Array.isArray(payload?.items)
|
||
? payload.items
|
||
: [];
|
||
const ruleById = new Map((categoryRule.properties || []).map((row) => [Number(row.refPid), row]));
|
||
const normalized = [];
|
||
for (const row of inputRows) {
|
||
const refPid = Number(row.refPid || row.ref_pid);
|
||
const rule = ruleById.get(refPid);
|
||
if (!rule) continue;
|
||
const requestedVid = Number(row.vid || 0);
|
||
const requestedValue = normalizeText(row.value, 500);
|
||
const enumValue = requestedVid
|
||
? (rule.values || []).find((value) => Number(value.vid) === requestedVid)
|
||
: (rule.values || []).find((value) => normalizeText(value.value, 500) === requestedValue);
|
||
if ((rule.values || []).length > 0 && !enumValue) continue;
|
||
const value = enumValue?.value || requestedValue;
|
||
if (!value) continue;
|
||
normalized.push({
|
||
refPid,
|
||
vid: enumValue?.vid || 0,
|
||
value,
|
||
propertyName: rule.name,
|
||
evidence: normalizeText(row.evidence || row.reason, 1000),
|
||
confidence: Math.min(Math.max(Number(row.confidence) || 0, 0), 1),
|
||
});
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
async function generatePddPropertyRecommendations(context = {}) {
|
||
const config = await getSelectionFissionAiConfig();
|
||
const model = normalizeText(
|
||
process.env.PDD_PROPERTY_AI_MODEL || DEFAULT_PDD_PROPERTY_MODEL,
|
||
160
|
||
) || DEFAULT_PDD_PROPERTY_MODEL;
|
||
const categoryRule = context.categoryRule || { properties: [] };
|
||
const lockedRefPids = new Set((context.lockedRefPids || []).map(Number));
|
||
const propertyRules = (categoryRule.properties || []).map((property) => ({
|
||
refPid: property.refPid,
|
||
name: property.name,
|
||
required: Boolean(property.required),
|
||
important: Boolean(property.important),
|
||
chooseMaxNum: Number(property.chooseMaxNum || 1),
|
||
valueType: property.valueType,
|
||
valueUnit: property.valueUnit,
|
||
values: (property.values || []).slice(0, 200),
|
||
lockedByExcel: lockedRefPids.has(Number(property.refPid)),
|
||
}));
|
||
const prompt = [
|
||
'你是拼多多商品发布属性分析专家。根据1688结构化商品参数、图片OCR文字和拼多多实时类目规则,推荐商品属性。',
|
||
'只能使用输入中真实存在的事实,不得猜测品牌、认证、环保等级、产地、材质或功能。证据不足的属性不要输出。',
|
||
'有枚举值的属性只能从给定values中选择,并同时返回对应refPid、vid和value;没有枚举值时才可填写文本value。',
|
||
'lockedByExcel=true的属性已由人工Excel规则确定,禁止输出或覆盖。优先处理必填和重要属性。',
|
||
'只输出JSON对象:{"properties":[{"refPid":123,"vid":456,"value":"枚举文本","confidence":0.95,"evidence":"来源证据"}]}。',
|
||
`拼多多叶子类目:${normalizeText(context.categoryName, 255)}`,
|
||
`拼多多类目路径:${JSON.stringify(context.categoryPath || [])}`,
|
||
`1688商品标题:${normalizeText(context.productTitle, 500)}`,
|
||
`1688类目:${normalizeText(context.sourceCategory, 500)}`,
|
||
`1688商品参数文案:${normalizeText(context.sourceDescription, 8000)}`,
|
||
`1688商品参数:${JSON.stringify(context.sourceAttributes || [])}`,
|
||
`图片OCR结果:${JSON.stringify((context.ocrResults || []).map((row) => ({
|
||
kind: row.kind,
|
||
index: row.index,
|
||
text: normalizeText(row.fullText, 3000),
|
||
})))}`,
|
||
`拼多多实时属性规则:${JSON.stringify(propertyRules)}`,
|
||
].join('\n');
|
||
const response = await requestChatCompletion({ ...config, model }, [{
|
||
role: 'user',
|
||
content: prompt,
|
||
}], Math.max(2000, Math.min(8000, propertyRules.length * 180)));
|
||
const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content);
|
||
if (!parsed) {
|
||
const error = new Error('qwen3.8-max未返回可解析的拼多多属性JSON');
|
||
error.status = 502;
|
||
error.code = 'PDD_PROPERTY_AI_JSON_MISSING';
|
||
throw error;
|
||
}
|
||
return {
|
||
model,
|
||
generatedAt: new Date().toISOString(),
|
||
properties: normalizePddPropertyRecommendations(parsed, categoryRule),
|
||
usage: {
|
||
promptTokens: Number(response?.usage?.prompt_tokens || 0),
|
||
completionTokens: Number(response?.usage?.completion_tokens || 0),
|
||
totalTokens: Number(response?.usage?.total_tokens || 0),
|
||
},
|
||
};
|
||
}
|
||
|
||
async function generateSkuNameCandidates(context = {}, skuRows = []) {
|
||
const normalizedRows = (Array.isArray(skuRows) ? skuRows : [])
|
||
.filter((row) => normalizeText(row?.skuCode || row?.sku_code, 160));
|
||
if (normalizedRows.length === 0) {
|
||
const error = new Error('没有可生成名称的SKU定价行');
|
||
error.status = 400;
|
||
throw error;
|
||
}
|
||
|
||
const config = await getSelectionFissionAiConfig();
|
||
const results = [];
|
||
let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||
for (let offset = 0; offset < normalizedRows.length; offset += MAX_SKUS_PER_REQUEST) {
|
||
const chunk = normalizedRows.slice(offset, offset + MAX_SKUS_PER_REQUEST);
|
||
let chunkRows = null;
|
||
let lastValidationError = null;
|
||
for (let attempt = 0; attempt < MAX_SKU_NAME_ATTEMPTS; attempt += 1) {
|
||
const requestContent = buildSkuNameRequestContent(context, chunk, config.skuNamePrompt);
|
||
if (attempt > 0) {
|
||
requestContent.push({
|
||
type: 'text',
|
||
text: buildSkuNameRepairInstruction(chunk, attempt),
|
||
});
|
||
}
|
||
const response = await requestChatCompletion(config, [{
|
||
role: 'user',
|
||
content: requestContent,
|
||
}], Math.max(1200, chunk.length * 450));
|
||
usage = {
|
||
promptTokens: usage.promptTokens + Number(response?.usage?.prompt_tokens || 0),
|
||
completionTokens: usage.completionTokens + Number(response?.usage?.completion_tokens || 0),
|
||
totalTokens: usage.totalTokens + Number(response?.usage?.total_tokens || 0),
|
||
};
|
||
const content = response?.choices?.[0]?.message?.content;
|
||
const parsed = parseFirstJsonObject(content);
|
||
try {
|
||
if (!parsed) {
|
||
const error = new Error('阿里云模型未返回可解析的SKU名称JSON');
|
||
error.status = 502;
|
||
error.code = 'AI_SKU_NAME_JSON_MISSING';
|
||
throw error;
|
||
}
|
||
chunkRows = normalizeSkuNameCandidatesPayload(parsed, chunk);
|
||
break;
|
||
} catch (error) {
|
||
lastValidationError = error;
|
||
if (attempt === MAX_SKU_NAME_ATTEMPTS - 1) throw error;
|
||
}
|
||
}
|
||
if (!chunkRows) throw lastValidationError;
|
||
results.push(...chunkRows);
|
||
}
|
||
|
||
return {
|
||
model: config.model,
|
||
generatedAt: new Date().toISOString(),
|
||
rows: results,
|
||
usage,
|
||
};
|
||
}
|
||
|
||
function normalizeKeywordList(value, limit = 20) {
|
||
const rows = Array.isArray(value) ? value : String(value || '').split(/[,,、\s]+/);
|
||
return Array.from(new Set(rows.map((item) => normalizeText(item, 40)).filter(Boolean))).slice(0, limit);
|
||
}
|
||
|
||
function normalizeProductTitleCategoryTerm(value) {
|
||
const categoryPath = normalizeText(value, 255);
|
||
if (!categoryPath) return '';
|
||
const segments = categoryPath
|
||
.split(/\s*(?:>|>|》|\/|\\|→|-)\s*/)
|
||
.map((item) => normalizeText(item, 40))
|
||
.filter(Boolean);
|
||
const leaf = segments.at(-1) || categoryPath;
|
||
const parentMatch = segments
|
||
.slice(0, -1)
|
||
.reverse()
|
||
.find((item) => item.length >= 2 && leaf.endsWith(item));
|
||
if (parentMatch) return parentMatch;
|
||
return leaf.replace(/^(?:单个|单件|单只|一体式)/, '') || leaf;
|
||
}
|
||
|
||
function normalizeProductTitleStrategy(value = {}, context = {}) {
|
||
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||
const category = normalizeProductTitleCategoryTerm(context.categoryName || raw.category);
|
||
const fallbackKeyword = category || normalizeText(context.spuName, 40) || '商品';
|
||
return {
|
||
brand: normalizeText(raw.brand, 40),
|
||
category: fallbackKeyword,
|
||
material: normalizeText(raw.material, 40),
|
||
functionalSellingPoints: normalizeKeywordList(
|
||
raw.functionalSellingPoints || raw.functional_selling_points || [fallbackKeyword],
|
||
5
|
||
),
|
||
scenes: normalizeKeywordList(raw.scenes || ['家用'], 3),
|
||
hotKeywords: normalizeKeywordList(raw.hotKeywords || raw.hot_keywords || [fallbackKeyword], 5),
|
||
surgingKeywords: normalizeKeywordList(
|
||
raw.surgingKeywords || raw.surging_keywords || [fallbackKeyword],
|
||
2
|
||
),
|
||
modifiers: normalizeKeywordList(raw.modifiers, 4),
|
||
categoryKeywords: normalizeKeywordList(raw.categoryKeywords || raw.category_keywords, 10),
|
||
competitorTitles: normalizeKeywordList(raw.competitorTitles || raw.competitor_titles || context.competitorTitles, 20),
|
||
forbiddenBrandTerms: normalizeKeywordList(
|
||
raw.forbiddenBrandTerms
|
||
|| raw.forbidden_brand_terms
|
||
|| context.forbiddenBrandTerms,
|
||
20
|
||
),
|
||
};
|
||
}
|
||
|
||
function normalizeProductTitleCandidatesPayload(payload, strategy = {}) {
|
||
const titles = Array.isArray(payload)
|
||
? payload
|
||
: Array.isArray(payload?.titles)
|
||
? payload.titles
|
||
: Array.isArray(payload?.items)
|
||
? payload.items
|
||
: [];
|
||
const normalized = Array.from(new Set(titles
|
||
.map((item) => normalizeGeneratedName(
|
||
typeof item === 'string' ? item : item?.title || item?.name
|
||
))
|
||
.filter(Boolean)
|
||
.map((title) => title.replace(/[\s,,。;;、|/]+/g, ''))
|
||
.filter((title) => title.length >= MIN_PRODUCT_TITLE_LENGTH && title.length <= MAX_PRODUCT_TITLE_LENGTH)));
|
||
if (normalized.length !== REQUIRED_NAME_COUNT) {
|
||
const error = new Error(`AI未能返回恰好${REQUIRED_NAME_COUNT}个有效商品标题(每个28至30字)`);
|
||
error.status = 502;
|
||
error.code = 'AI_PRODUCT_TITLE_INVALID_RESPONSE';
|
||
throw error;
|
||
}
|
||
const category = normalizeText(strategy.category, 40);
|
||
const requiredWords = normalizeKeywordList(strategy.surgingKeywords);
|
||
if (category && normalized.some((title) => !title.includes(category))) {
|
||
const error = new Error(`AI生成的标题未全部包含品类词“${category}”`);
|
||
error.status = 502;
|
||
error.code = 'AI_PRODUCT_TITLE_CATEGORY_MISSING';
|
||
throw error;
|
||
}
|
||
const missingSurging = requiredWords.find((word) => normalized.some((title) => !title.includes(word)));
|
||
if (missingSurging) {
|
||
const error = new Error(`AI生成的标题未全部包含飙升词“${missingSurging}”`);
|
||
error.status = 502;
|
||
error.code = 'AI_PRODUCT_TITLE_SURGING_MISSING';
|
||
throw error;
|
||
}
|
||
const forbiddenBrandTerm = normalizeKeywordList(strategy.forbiddenBrandTerms, 20)
|
||
.find((term) => normalized.some((title) => title.toLowerCase().includes(term.toLowerCase())));
|
||
if (forbiddenBrandTerm) {
|
||
const error = new Error(`AI生成的标题包含参考商品品牌或型号“${forbiddenBrandTerm}”`);
|
||
error.status = 502;
|
||
error.code = 'AI_PRODUCT_TITLE_REFERENCE_BRAND_LEAK';
|
||
throw error;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
async function generateProductTitleCandidates(context = {}) {
|
||
const config = await getSelectionFissionAiConfig();
|
||
const strategy = normalizeProductTitleStrategy(context.keywordStrategy, context);
|
||
const baseText = [
|
||
config.productTitlePrompt,
|
||
'',
|
||
`市场商品标题:${normalizeText(context.marketTitle, 500) || '未提供'}`,
|
||
`公司SPU编码:${normalizeText(context.spuCode, 160) || '未提供'}`,
|
||
`公司SPU名称:${normalizeText(context.spuName, 255) || '未提供'}`,
|
||
`商品类目:${normalizeText(context.categoryName, 255) || '未提供'}`,
|
||
`关键词策略:${JSON.stringify(strategy)}`,
|
||
`榜单竞品标题(最多20条):${JSON.stringify(strategy.competitorTitles)}`,
|
||
`品类候选词(最多10个):${JSON.stringify(strategy.categoryKeywords)}`,
|
||
`禁用的参考品牌或型号:${JSON.stringify(strategy.forbiddenBrandTerms)}`,
|
||
'请先基于商品事实、竞品标题和候选词优化 keywordStrategy 中各分类推荐词,再用推荐结果组题。不得虚构商品属性。',
|
||
'参考标题里的第三方品牌、系列名和品牌型号只能写入forbiddenBrandTerms,严禁出现在任何候选标题中。',
|
||
'硬性长度要求:每个标题必须为28至30个汉字,在语义自然且不重复堆砌的前提下优先写满30字。',
|
||
].join('\n');
|
||
const imageUrl = normalizeText(context.imageUrl, 2000);
|
||
let lastValidationError = null;
|
||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||
const content = [{
|
||
type: 'text',
|
||
text: attempt === 1
|
||
? baseText
|
||
: `${baseText}\n\n上次输出未通过校验:${lastValidationError?.message || '格式不合规'}。请重新生成,严格保证5个标题各自为28至30字、优先写满30字,并包含品类词和全部飙升词。`,
|
||
}];
|
||
if (imageUrl) {
|
||
content.push({ type: 'text', text: '商品主图:' });
|
||
content.push({ type: 'image_url', image_url: { url: imageUrl } });
|
||
}
|
||
const response = await requestChatCompletion(config, [{ role: 'user', content }], 1000);
|
||
const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content);
|
||
try {
|
||
if (!parsed) {
|
||
const error = new Error('阿里云模型未返回可解析的商品标题JSON');
|
||
error.status = 502;
|
||
error.code = 'AI_PRODUCT_TITLE_JSON_MISSING';
|
||
throw error;
|
||
}
|
||
const recommendedRaw = parsed.keywordStrategy || parsed.keyword_strategy || {};
|
||
const recommendedStrategy = normalizeProductTitleStrategy({
|
||
...strategy,
|
||
...recommendedRaw,
|
||
category: strategy.category,
|
||
categoryKeywords: strategy.categoryKeywords,
|
||
competitorTitles: strategy.competitorTitles,
|
||
forbiddenBrandTerms: [
|
||
...strategy.forbiddenBrandTerms,
|
||
...normalizeKeywordList(
|
||
recommendedRaw.forbiddenBrandTerms || recommendedRaw.forbidden_brand_terms,
|
||
20
|
||
),
|
||
],
|
||
}, context);
|
||
return {
|
||
model: config.model,
|
||
generatedAt: new Date().toISOString(),
|
||
strategy: recommendedStrategy,
|
||
titles: normalizeProductTitleCandidatesPayload(parsed, recommendedStrategy),
|
||
usage: {
|
||
promptTokens: Number(response?.usage?.prompt_tokens || 0),
|
||
completionTokens: Number(response?.usage?.completion_tokens || 0),
|
||
totalTokens: Number(response?.usage?.total_tokens || 0),
|
||
},
|
||
};
|
||
} catch (error) {
|
||
lastValidationError = error;
|
||
if (attempt === 3) throw error;
|
||
}
|
||
}
|
||
throw lastValidationError;
|
||
}
|
||
|
||
async function testSelectionFissionAiConfig() {
|
||
const config = await getSelectionFissionAiConfig();
|
||
const response = await requestChatCompletion(config, [{
|
||
role: 'user',
|
||
content: '只返回JSON:{"ok":true,"message":"连接成功"}',
|
||
}], 100);
|
||
const parsed = parseFirstJsonObject(response?.choices?.[0]?.message?.content);
|
||
if (parsed?.ok !== true) {
|
||
const error = new Error('模型已响应,但未返回预期的连接测试结果');
|
||
error.status = 502;
|
||
throw error;
|
||
}
|
||
return {
|
||
provider: config.provider,
|
||
model: config.model,
|
||
message: normalizeText(parsed.message, 100) || '连接成功',
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
CONFIG_KEY,
|
||
DEFAULT_BASE_URL,
|
||
DEFAULT_EXTERNAL_IMAGE_BASE_URL,
|
||
DEFAULT_EXTERNAL_IMAGE_MODEL,
|
||
DEFAULT_MAIN_IMAGE_PROMPT,
|
||
DEFAULT_MODEL,
|
||
DEFAULT_PDD_PROPERTY_MODEL,
|
||
DEFAULT_PRODUCT_TITLE_PROMPT,
|
||
DEFAULT_QWEN_IMAGE_BASE_URL,
|
||
DEFAULT_QWEN_IMAGE_MODEL,
|
||
DEFAULT_SKU_IMAGE_PROMPT,
|
||
DEFAULT_SKU_NAME_PROMPT,
|
||
buildSkuNameRepairInstruction,
|
||
generateProductTitleCandidates,
|
||
generatePddPropertyRecommendations,
|
||
generateSkuNameCandidates,
|
||
getSelectionFissionAiAdminSettings,
|
||
getSelectionFissionAiConfig,
|
||
getSelectionFissionImageGenerationConfig,
|
||
normalizeImageGenerationConfig,
|
||
normalizeProductTitleCandidatesPayload,
|
||
normalizeProductTitleCategoryTerm,
|
||
normalizePddPropertyRecommendations,
|
||
normalizeMainImagePrompt,
|
||
normalizeSkuImagePrompt,
|
||
normalizeSkuNameCandidatesPayload,
|
||
saveSelectionFissionAiConfig,
|
||
testSelectionFissionAiConfig,
|
||
};
|