475 lines
19 KiB
JavaScript
475 lines
19 KiB
JavaScript
'use strict';
|
||
|
||
const axios = require('axios');
|
||
const { getAlibaba1688Product } = require('./alibaba1688ProductService');
|
||
const {
|
||
getEncodingByProductId,
|
||
getClaimTtlSeconds,
|
||
} = require('./alibaba1688ProductEncodingRegistry');
|
||
const {
|
||
buildAlibaba1688SkuKey,
|
||
buildAlibaba1688SkuFingerprint,
|
||
} = require('./alibaba1688SkuIdentity');
|
||
const { getSelectionFissionAiConfig } = require('./selectionFissionAiSettings');
|
||
|
||
const SKU_DIMENSION_MODEL = 'qwen3.7-flash';
|
||
const MAX_SKUS_PER_REQUEST = 30;
|
||
const REQUEST_CONCURRENCY = 2;
|
||
const MAX_REQUEST_ATTEMPTS = 2;
|
||
const MAX_MODEL_REQUEST_TIMEOUT_MS = 60000;
|
||
const MAX_DIMENSION_CM = 100000;
|
||
const MAX_CODING_SKUS = 200;
|
||
|
||
const DIMENSION_PROMPT = [
|
||
'你是1688电商商品SKU尺寸解析专家。请分析商品资料以及每一条SKU名称和规格,为每条SKU给出商品本体的长、宽、高。',
|
||
'所有数值统一换算成厘米,不要使用包装尺寸、物流尺寸或重量。长表示主要水平长度,宽表示另一水平尺寸或进深,高表示垂直高度。',
|
||
'优先采用SKU名称和规格中明确出现的尺寸;其次结合同一商品其他SKU的尺寸规律、商品标题、类目和属性推断缺失维度。',
|
||
'SKU名称或规格中出现的cm、mm、米尺寸是硬约束:每个明确数值都必须出现在该SKU返回的长、宽、高之一。只有一个尺寸值时,请结合品类判断它属于长、宽还是高;不同明确尺寸的SKU不得返回完全相同的长宽高。',
|
||
'同一尺寸仅颜色不同的SKU必须返回完全相同的长宽高。即使信息不完整,也要给出最合理的正数估计,并在evidence中简短说明依据。',
|
||
'只输出JSON对象,格式为:{"items":[{"skuRef":"S1","lengthCm":120,"widthCm":60,"heightCm":75,"confidence":"high","evidence":"SKU明确标注120×60×75cm"}]}。',
|
||
'必须为输入中的每个skuRef返回且只返回一条结果,不得遗漏、重复、改写skuRef或返回null。',
|
||
].join('\n');
|
||
|
||
function dimensionError(message, statusCode = 502, code = 'ALIBABA_1688_SKU_DIMENSION_AI_FAILED') {
|
||
const error = new Error(message);
|
||
error.statusCode = statusCode;
|
||
error.status = statusCode;
|
||
error.code = code;
|
||
return error;
|
||
}
|
||
|
||
function normalizeText(value, maxLength = 1000) {
|
||
return String(value ?? '').trim().slice(0, maxLength);
|
||
}
|
||
|
||
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 {
|
||
const start = candidate.indexOf('{');
|
||
const end = candidate.lastIndexOf('}');
|
||
if (start >= 0 && end > start) {
|
||
try {
|
||
return JSON.parse(candidate.slice(start, end + 1));
|
||
} catch {
|
||
// Try the next candidate.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeDimension(value, label) {
|
||
let number = Number(value);
|
||
if (!Number.isFinite(number) && typeof value === 'string') {
|
||
const match = value.trim().match(/(-?\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|m|米)?/i);
|
||
if (match) {
|
||
number = Number(match[1]);
|
||
const unit = String(match[2] || 'cm').toLowerCase();
|
||
if (unit === 'mm' || unit === '毫米') number /= 10;
|
||
if (unit === 'm' || unit === '米') number *= 100;
|
||
}
|
||
}
|
||
if (!Number.isFinite(number) || number <= 0 || number > MAX_DIMENSION_CM) {
|
||
throw dimensionError(
|
||
`AI返回的${label}不是有效的厘米正数`,
|
||
502,
|
||
'ALIBABA_1688_SKU_DIMENSION_AI_INVALID_VALUE'
|
||
);
|
||
}
|
||
return Math.round(number * 100) / 100;
|
||
}
|
||
|
||
function convertExplicitDimensionToCm(value, unit) {
|
||
let number = Number(value);
|
||
if (!Number.isFinite(number) || number <= 0) return null;
|
||
const normalizedUnit = String(unit || 'cm').toLowerCase();
|
||
if (normalizedUnit === 'mm' || normalizedUnit === '毫米') number /= 10;
|
||
if (normalizedUnit === 'm' || normalizedUnit === '米') number *= 100;
|
||
return number <= MAX_DIMENSION_CM ? Math.round(number * 100) / 100 : null;
|
||
}
|
||
|
||
function extractExplicitDimensionValues(row = {}) {
|
||
const text = [
|
||
row.skuName,
|
||
...(Array.isArray(row.specs) ? row.specs.flatMap(spec => [spec?.name, spec?.value]) : []),
|
||
].map(value => normalizeText(value, 1200)).filter(Boolean).join(' ');
|
||
const values = new Set();
|
||
const add = (value, unit) => {
|
||
const centimeters = convertExplicitDimensionToCm(value, unit);
|
||
if (centimeters != null) values.add(centimeters);
|
||
};
|
||
const groupedPattern = /(\d+(?:\.\d+)?)\s*[xX×*]\s*(\d+(?:\.\d+)?)(?:\s*[xX×*]\s*(\d+(?:\.\d+)?))?\s*(mm|毫米|cm|厘米|公分|m|米)/gi;
|
||
for (const match of text.matchAll(groupedPattern)) {
|
||
add(match[1], match[4]);
|
||
add(match[2], match[4]);
|
||
if (match[3]) add(match[3], match[4]);
|
||
}
|
||
const singlePattern = /(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|公分|m|米)/gi;
|
||
for (const match of text.matchAll(singlePattern)) add(match[1], match[2]);
|
||
return [...values];
|
||
}
|
||
|
||
function assertExplicitDimensionsPreserved(rows = [], requestRows = []) {
|
||
for (let index = 0; index < requestRows.length; index += 1) {
|
||
const explicitValues = extractExplicitDimensionValues(requestRows[index]);
|
||
if (!explicitValues.length) continue;
|
||
const actualValues = [rows[index]?.lengthCm, rows[index]?.widthCm, rows[index]?.heightCm]
|
||
.map(Number)
|
||
.filter(Number.isFinite);
|
||
const ignoredValues = explicitValues.filter(value => !actualValues.some(actual => (
|
||
Math.abs(actual - value) <= Math.max(0.05, value * 0.005)
|
||
)));
|
||
if (ignoredValues.length) {
|
||
throw dimensionError(
|
||
`Qwen忽略了 ${requestRows[index].skuRef} 规格中的明确尺寸 ${ignoredValues.join('/')}cm`,
|
||
502,
|
||
'ALIBABA_1688_SKU_DIMENSION_AI_EXPLICIT_VALUE_IGNORED'
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeDimensionPayload(payload, requestRows = []) {
|
||
const rawItems = Array.isArray(payload) ? payload : payload?.items;
|
||
if (!Array.isArray(rawItems)) {
|
||
throw dimensionError(
|
||
'Qwen未返回可解析的SKU尺寸列表',
|
||
502,
|
||
'ALIBABA_1688_SKU_DIMENSION_AI_JSON_INVALID'
|
||
);
|
||
}
|
||
const byRef = new Map();
|
||
for (const item of rawItems) {
|
||
const skuRef = normalizeText(item?.skuRef || item?.sku_ref, 40);
|
||
if (!skuRef || byRef.has(skuRef)) continue;
|
||
const dimensions = item?.dimensions && typeof item.dimensions === 'object'
|
||
? item.dimensions
|
||
: item;
|
||
byRef.set(skuRef, {
|
||
skuRef,
|
||
lengthCm: normalizeDimension(dimensions.lengthCm ?? dimensions.length_cm ?? dimensions.length, '长度'),
|
||
widthCm: normalizeDimension(dimensions.widthCm ?? dimensions.width_cm ?? dimensions.width, '宽度'),
|
||
heightCm: normalizeDimension(dimensions.heightCm ?? dimensions.height_cm ?? dimensions.height, '高度'),
|
||
confidence: ['high', 'medium', 'low'].includes(normalizeText(item?.confidence, 20).toLowerCase())
|
||
? normalizeText(item.confidence, 20).toLowerCase()
|
||
: 'medium',
|
||
evidence: normalizeText(item?.evidence || item?.reason, 300),
|
||
});
|
||
}
|
||
|
||
const missingRef = requestRows.find((row) => !byRef.has(row.skuRef))?.skuRef;
|
||
if (missingRef || byRef.size !== requestRows.length) {
|
||
throw dimensionError(
|
||
`Qwen返回的SKU尺寸不完整${missingRef ? `,缺少 ${missingRef}` : ''}`,
|
||
502,
|
||
'ALIBABA_1688_SKU_DIMENSION_AI_INCOMPLETE'
|
||
);
|
||
}
|
||
return requestRows.map((row) => ({ ...row, ...byRef.get(row.skuRef) }));
|
||
}
|
||
|
||
function buildAnalysisRows(productRecord) {
|
||
const sourceSkus = Array.isArray(productRecord?.product?.skus) ? productRecord.product.skus : [];
|
||
if (!sourceSkus.length) {
|
||
throw dimensionError('该1688商品没有可解析的SKU', 422, 'ALIBABA_1688_SKU_REQUIRED');
|
||
}
|
||
if (sourceSkus.length > MAX_CODING_SKUS) {
|
||
throw dimensionError(
|
||
`该商品共有 ${sourceSkus.length} 个SKU,超过编码上限 ${MAX_CODING_SKUS} 个`,
|
||
422,
|
||
'ALIBABA_1688_SKU_LIMIT_EXCEEDED'
|
||
);
|
||
}
|
||
return sourceSkus.map((sku, index) => ({
|
||
skuRef: `S${index + 1}`,
|
||
sourceSkuKey: buildAlibaba1688SkuKey(sku, index),
|
||
sourceIndex: Number(sku?.index) || index + 1,
|
||
skuName: normalizeText(sku?.name, 1200),
|
||
specs: (Array.isArray(sku?.specs) ? sku.specs : []).map((spec) => ({
|
||
name: normalizeText(spec?.name, 120),
|
||
value: normalizeText(spec?.value, 500),
|
||
})),
|
||
}));
|
||
}
|
||
|
||
function buildDimensionAnalysisAttributes(product = {}) {
|
||
const attributes = (Array.isArray(product.attributes) ? product.attributes : []).slice(0, 80).map((item) => ({
|
||
name: normalizeText(item?.name, 160),
|
||
value: normalizeText(item?.value, 500),
|
||
}));
|
||
const hasLogisticsTable = attributes.some((item) => {
|
||
const text = `${item.name} ${item.value}`;
|
||
return /体积/i.test(text) && /(重量|毛重|净重)/i.test(text);
|
||
});
|
||
return attributes.filter((item) => {
|
||
const text = `${item.name} ${item.value}`;
|
||
if (/(包装尺寸|物流尺寸|包装体积|体积.*(?:重量|毛重|净重))/i.test(text)) return false;
|
||
if (hasLogisticsTable && /^(?:\s*\d+(?:\.\d+)?){4,}\s*$/.test(item.value)) return false;
|
||
return item.name || item.value;
|
||
});
|
||
}
|
||
|
||
function buildProductContext(productRecord, rows) {
|
||
const product = productRecord?.product || {};
|
||
return {
|
||
productId: normalizeText(productRecord?.productId, 80),
|
||
title: normalizeText(productRecord?.title || product.title, 500),
|
||
category: normalizeText(productRecord?.category || product.category, 500),
|
||
attributes: buildDimensionAnalysisAttributes(product),
|
||
skus: rows.map((row) => ({
|
||
skuRef: row.skuRef,
|
||
skuName: row.skuName,
|
||
specs: row.specs,
|
||
explicitDimensionValuesCm: extractExplicitDimensionValues(row),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function dimensionsFromSavedRequest(productRecord, encoding) {
|
||
const rows = buildAnalysisRows(productRecord);
|
||
const savedItems = Array.isArray(encoding?.request?.skuItems) ? encoding.request.skuItems : [];
|
||
if (savedItems.length !== rows.length) return [];
|
||
return rows.map((row, index) => ({
|
||
sourceSkuKey: row.sourceSkuKey,
|
||
sourceIndex: row.sourceIndex,
|
||
lengthCm: savedItems[index]?.lengthCm ?? null,
|
||
widthCm: savedItems[index]?.widthCm ?? null,
|
||
heightCm: savedItems[index]?.heightCm ?? null,
|
||
confidence: 'saved',
|
||
evidence: '首次正式编码请求已保存',
|
||
}));
|
||
}
|
||
|
||
function hasCompleteSavedDimensions(rows) {
|
||
return Array.isArray(rows) && rows.length > 0 && rows.every(row => (
|
||
Number.isFinite(Number(row.lengthCm)) && Number(row.lengthCm) > 0
|
||
&& Number.isFinite(Number(row.widthCm)) && Number(row.widthCm) > 0
|
||
&& Number.isFinite(Number(row.heightCm)) && Number(row.heightCm) > 0
|
||
));
|
||
}
|
||
|
||
function assertRetrySnapshotCurrent(productRecord, encoding) {
|
||
const rows = buildAnalysisRows(productRecord);
|
||
const savedItems = Array.isArray(encoding?.request?.skuItems) ? encoding.request.skuItems : [];
|
||
const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(productRecord.product?.skus);
|
||
const snapshotMatches = savedItems.length === rows.length
|
||
&& (!Number(encoding?.sourceSkuCount) || Number(encoding.sourceSkuCount) === rows.length)
|
||
&& (!encoding?.sourceSkuFingerprint
|
||
|| encoding.sourceSkuFingerprint === sourceSkuFingerprint);
|
||
if (!snapshotMatches) {
|
||
throw dimensionError(
|
||
'商品 SKU 已在首次编码请求后发生变化,不能复用历史请求或重新解析尺寸',
|
||
409,
|
||
'ALIBABA_1688_SKU_CHANGED_SINCE_ENCODING_REQUEST'
|
||
);
|
||
}
|
||
return { rows, sourceSkuFingerprint };
|
||
}
|
||
|
||
function isRetryableEncodingRecord(encoding, now = Date.now()) {
|
||
if (['failed', 'conflict'].includes(encoding?.status)) return true;
|
||
if (encoding?.status !== 'encoding') return false;
|
||
const startedAt = new Date(encoding.startedAt).getTime();
|
||
return Number.isFinite(startedAt)
|
||
&& startedAt + getClaimTtlSeconds() * 1000 <= Number(now);
|
||
}
|
||
|
||
function isRetryableModelError(error) {
|
||
const status = Number(error?.response?.status || error?.statusCode || error?.status || 0);
|
||
return !error?.response || [408, 425, 429].includes(status) || status >= 500;
|
||
}
|
||
|
||
function delay(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function requestDimensionChunk(config, context, requestRows, options = {}) {
|
||
const httpClient = options.httpClient || axios;
|
||
const wait = options.delay || delay;
|
||
let lastError;
|
||
for (let attempt = 1; attempt <= MAX_REQUEST_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
const correction = attempt > 1 && lastError
|
||
? `\n\n上次结果未通过校验:${normalizeText(lastError.message, 500)}。请重新逐条检查SKU中的明确尺寸,确保这些数值实际出现在对应长、宽、高中。`
|
||
: '';
|
||
const response = await httpClient.post(`${String(config.baseUrl).replace(/\/+$/, '')}/chat/completions`, {
|
||
model: SKU_DIMENSION_MODEL,
|
||
messages: [{
|
||
role: 'user',
|
||
content: `${DIMENSION_PROMPT}${correction}\n\n商品及SKU资料:${JSON.stringify(context)}`,
|
||
}],
|
||
response_format: { type: 'json_object' },
|
||
max_completion_tokens: Math.min(6000, Math.max(1200, requestRows.length * 130)),
|
||
enable_thinking: false,
|
||
temperature: 0,
|
||
}, {
|
||
timeout: Math.min(
|
||
Math.max(Number(config.timeoutMs) || MAX_MODEL_REQUEST_TIMEOUT_MS, 1000),
|
||
MAX_MODEL_REQUEST_TIMEOUT_MS
|
||
),
|
||
headers: {
|
||
Authorization: `Bearer ${config.apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
maxBodyLength: 10 * 1024 * 1024,
|
||
});
|
||
const parsed = parseFirstJsonObject(response?.data?.choices?.[0]?.message?.content);
|
||
const rows = normalizeDimensionPayload(parsed, requestRows);
|
||
assertExplicitDimensionsPreserved(rows, requestRows);
|
||
return {
|
||
rows,
|
||
usage: {
|
||
promptTokens: Number(response?.data?.usage?.prompt_tokens || 0),
|
||
completionTokens: Number(response?.data?.usage?.completion_tokens || 0),
|
||
totalTokens: Number(response?.data?.usage?.total_tokens || 0),
|
||
},
|
||
};
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (attempt >= MAX_REQUEST_ATTEMPTS || !isRetryableModelError(error)) break;
|
||
await wait(500 * attempt);
|
||
}
|
||
}
|
||
if (lastError?.code?.startsWith('ALIBABA_1688_SKU_DIMENSION_')) throw lastError;
|
||
const upstreamMessage = normalizeText(
|
||
lastError?.response?.data?.error?.message || lastError?.response?.data?.message || lastError?.message,
|
||
500
|
||
);
|
||
throw dimensionError(
|
||
`Qwen智能解析SKU尺寸失败:${upstreamMessage || '模型服务暂不可用'}`,
|
||
Number(lastError?.response?.status) || 502,
|
||
'ALIBABA_1688_SKU_DIMENSION_AI_REQUEST_FAILED'
|
||
);
|
||
}
|
||
|
||
async function mapWithConcurrency(items, concurrency, mapper) {
|
||
const results = new Array(items.length);
|
||
let nextIndex = 0;
|
||
async function worker() {
|
||
while (nextIndex < items.length) {
|
||
const index = nextIndex;
|
||
nextIndex += 1;
|
||
results[index] = await mapper(items[index], index);
|
||
}
|
||
}
|
||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
||
return results;
|
||
}
|
||
|
||
async function analyzeAlibaba1688SkuDimensions(productId, options = {}) {
|
||
const productRecord = await (options.getProduct || getAlibaba1688Product)(productId);
|
||
const encoding = await (options.getEncoding || getEncodingByProductId)(productId);
|
||
if (encoding?.status === 'encoded') {
|
||
return {
|
||
alreadyEncoded: true,
|
||
source: 'encoded-request',
|
||
model: null,
|
||
rows: dimensionsFromSavedRequest(productRecord, encoding),
|
||
};
|
||
}
|
||
if (encoding?.request) {
|
||
assertRetrySnapshotCurrent(productRecord, encoding);
|
||
const savedRows = dimensionsFromSavedRequest(productRecord, encoding);
|
||
if (hasCompleteSavedDimensions(savedRows)) {
|
||
return {
|
||
alreadyEncoded: false,
|
||
alreadyRequested: true,
|
||
source: 'saved-request',
|
||
model: null,
|
||
rows: savedRows,
|
||
};
|
||
}
|
||
const now = typeof options.now === 'function' ? options.now() : (options.now ?? Date.now());
|
||
if (!isRetryableEncodingRecord(encoding, now)) {
|
||
throw dimensionError(
|
||
'该商品的编码请求仍在处理中,暂不能重新解析 SKU 尺寸',
|
||
409,
|
||
'ALIBABA_1688_ENCODING_IN_PROGRESS'
|
||
);
|
||
}
|
||
}
|
||
|
||
const config = await (options.getConfig || getSelectionFissionAiConfig)();
|
||
if (!config.enabled) {
|
||
throw dimensionError('选品AI分析模型未启用', 409, 'SELECTION_AI_DISABLED');
|
||
}
|
||
if (!config.apiKey) {
|
||
throw dimensionError('选品AI分析模型尚未配置API Key', 409, 'SELECTION_AI_API_KEY_MISSING');
|
||
}
|
||
|
||
const rows = buildAnalysisRows(productRecord);
|
||
const sourceSkuFingerprint = buildAlibaba1688SkuFingerprint(productRecord.product?.skus);
|
||
const chunks = [];
|
||
for (let offset = 0; offset < rows.length; offset += MAX_SKUS_PER_REQUEST) {
|
||
chunks.push(rows.slice(offset, offset + MAX_SKUS_PER_REQUEST));
|
||
}
|
||
const requestChunk = options.requestChunk || requestDimensionChunk;
|
||
const chunkResults = await mapWithConcurrency(
|
||
chunks,
|
||
Number(options.concurrency) || REQUEST_CONCURRENCY,
|
||
(chunk) => requestChunk(config, buildProductContext(productRecord, chunk), chunk, options)
|
||
);
|
||
const usage = chunkResults.reduce((total, result) => ({
|
||
promptTokens: total.promptTokens + result.usage.promptTokens,
|
||
completionTokens: total.completionTokens + result.usage.completionTokens,
|
||
totalTokens: total.totalTokens + result.usage.totalTokens,
|
||
}), { promptTokens: 0, completionTokens: 0, totalTokens: 0 });
|
||
const latestProduct = await (
|
||
options.getLatestProduct || options.getProduct || getAlibaba1688Product
|
||
)(productId);
|
||
const latestFingerprint = buildAlibaba1688SkuFingerprint(latestProduct.product?.skus);
|
||
if (!sourceSkuFingerprint || latestFingerprint !== sourceSkuFingerprint) {
|
||
throw dimensionError(
|
||
'智能解析期间商品SKU发生变化,请关闭窗口后重新加载',
|
||
409,
|
||
'ALIBABA_1688_SKU_CHANGED_DURING_AI_ANALYSIS'
|
||
);
|
||
}
|
||
|
||
return {
|
||
alreadyEncoded: false,
|
||
alreadyRequested: Boolean(encoding?.request),
|
||
source: 'qwen',
|
||
model: SKU_DIMENSION_MODEL,
|
||
analyzedAt: new Date().toISOString(),
|
||
sourceSkuFingerprint,
|
||
skuCount: rows.length,
|
||
batchCount: chunks.length,
|
||
rows: chunkResults.flatMap((result) => result.rows).map((row) => ({
|
||
sourceSkuKey: row.sourceSkuKey,
|
||
sourceIndex: row.sourceIndex,
|
||
lengthCm: row.lengthCm,
|
||
widthCm: row.widthCm,
|
||
heightCm: row.heightCm,
|
||
confidence: row.confidence,
|
||
evidence: row.evidence,
|
||
})),
|
||
usage,
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
SKU_DIMENSION_MODEL,
|
||
MAX_SKUS_PER_REQUEST,
|
||
MAX_MODEL_REQUEST_TIMEOUT_MS,
|
||
DIMENSION_PROMPT,
|
||
analyzeAlibaba1688SkuDimensions,
|
||
assertExplicitDimensionsPreserved,
|
||
buildAnalysisRows,
|
||
buildDimensionAnalysisAttributes,
|
||
dimensionsFromSavedRequest,
|
||
extractExplicitDimensionValues,
|
||
hasCompleteSavedDimensions,
|
||
assertRetrySnapshotCurrent,
|
||
isRetryableEncodingRecord,
|
||
normalizeDimensionPayload,
|
||
parseFirstJsonObject,
|
||
requestDimensionChunk,
|
||
};
|