feat(ai-edit): 开发 AI 填报功能

This commit is contained in:
R524809
2026-09-23 17:18:09 +08:00
parent 95a2a37f1b
commit b3958166dd
35 changed files with 2832 additions and 278 deletions
+105 -37
View File
@@ -19,23 +19,34 @@ import {
type CatalogSyncScope,
} from './ozon-catalog-sync.js';
import { scheduleStoreCategoryTreeSync } from './store-category-tree-scheduler.js';
import { PlatformCatalogDispatcher, type PlatformCatalogJob } from './platform-catalog-dispatcher.js';
import {
PlatformCatalogDispatcher,
type PlatformCatalogJob,
} from './platform-catalog-dispatcher.js';
import { PlatformCatalogScheduler } from './platform-catalog-scheduler.js';
import { markPlatformCatalogUnavailable, syncPlatformCatalog } from './platform-catalog-sync.js';
import {
markPlatformCatalogUnavailable,
syncPlatformCatalog,
} from './platform-catalog-sync.js';
import { runProductAiTask } from './product-ai.js';
const connection = new Redis(workerConfig.redisUrl, {
maxRetriesPerRequest: null,
});
const database = createApplicationDatabase(workerConfig.databaseUrl);
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
const platformQueue = new Queue<PlatformCatalogJob>('atlas-platform-catalog', { connection });
const platformQueue = new Queue<PlatformCatalogJob>('atlas-platform-catalog', {
connection,
});
const dispatcher = new OutboxDispatcher(
database,
queue,
`worker_${randomUUID()}`,
);
const platformDispatcher = new PlatformCatalogDispatcher(
database, platformQueue, `platform_worker_${randomUUID()}`,
database,
platformQueue,
`platform_worker_${randomUUID()}`,
);
const platformScheduler = new PlatformCatalogScheduler(database);
const objectStorage = new S3Client({
@@ -48,7 +59,9 @@ const objectStorage = new S3Client({
},
});
const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage);
type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } };
type StoreVerificationJob = OutboxJob & {
payload: { store_id: string; operation_id: string; secret_id?: string };
};
type CatalogSyncJob = OutboxJob & {
payload: {
store_id: string;
@@ -67,12 +80,39 @@ const worker = new Worker<OutboxJob | PlatformCatalogJob>(
async (job) => {
if (job.name === 'platform_catalog_sync.requested') {
await syncPlatformCatalog(database, job.data as PlatformCatalogJob);
} else if (job.name === 'product_ai_task.requested') {
await runProductAiTask(database, job.data as OutboxJob);
} else if (job.name === 'store_credential_verification.requested') {
await verifyStoreCredentials(database, job.data as OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } });
await verifyStoreCredentials(
database,
job.data as OutboxJob & {
payload: {
store_id: string;
operation_id: string;
secret_id?: string;
};
},
);
} else if (job.name === 'collection.cleanup.requested') {
await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } });
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data as OutboxJob);
await cleanupCollection(
database,
job.data as OutboxJob & {
payload: {
operation_id: string;
collection_item_ids: string[];
};
},
);
} else if (
job.name === 'collection.assets.materialize.requested' ||
job.name === 'collection.assets.retry.requested'
) {
await materializeCollectionAssets(
database,
objectStorage,
workerConfig.objectStorageBucket,
job.data as OutboxJob,
);
} else if (
job.name === 'store_category_tree_sync.requested' ||
job.name === 'store_category_attribute_sync.requested' ||
@@ -91,18 +131,35 @@ const platformWorker = new Worker<PlatformCatalogJob>(
{ connection, concurrency: 5 },
);
async function cleanupCollection(database: Database, job: OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } }) {
async function cleanupCollection(
database: Database,
job: OutboxJob & {
payload: { operation_id: string; collection_item_ids: string[] };
},
) {
await database.workspaceTransaction(job.workspaceId, async (tx) => {
await tx.query(`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 WHERE id = $1 AND status = 'queued'`, [job.payload.operation_id]);
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', updated_at = now(), completed_at = now() WHERE collection_item_id = ANY($1::uuid[]) AND status IN ('queued', 'running')`, [job.payload.collection_item_ids]);
await tx.query(`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), result = jsonb_build_object('cancelled_transfer_count', 0) WHERE id = $1 AND status IN ('queued', 'running')`, [job.payload.operation_id]);
await tx.query(
`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 WHERE id = $1 AND status = 'queued'`,
[job.payload.operation_id],
);
await tx.query(
`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', updated_at = now(), completed_at = now() WHERE collection_item_id = ANY($1::uuid[]) AND status IN ('queued', 'running')`,
[job.payload.collection_item_ids],
);
await tx.query(
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), result = jsonb_build_object('cancelled_transfer_count', 0) WHERE id = $1 AND status IN ('queued', 'running')`,
[job.payload.operation_id],
);
});
}
worker.on('failed', (job, error) => {
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
if (job.name === 'platform_catalog_sync.requested') {
void markPlatformCatalogUnavailable(database, job.data as PlatformCatalogJob);
void markPlatformCatalogUnavailable(
database,
job.data as PlatformCatalogJob,
);
} else if (job.name === 'store_credential_verification.requested') {
void markUnavailable(database, job.data as StoreVerificationJob, error);
} else if (
@@ -110,7 +167,11 @@ worker.on('failed', (job, error) => {
job.name === 'store_category_attribute_sync.requested' ||
job.name === 'store_attribute_dictionary_sync.requested'
) {
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
void markCatalogSyncUnavailable(
database,
job.data as CatalogSyncJob,
error,
);
}
});
platformWorker.on('failed', (job) => {
@@ -118,7 +179,10 @@ platformWorker.on('failed', (job) => {
void markPlatformCatalogUnavailable(database, job.data);
});
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
async function verifyStoreCredentials(
database: Database,
job: StoreVerificationJob,
) {
const source = await database.workspaceTransaction(
job.workspaceId,
async (tx) => {
@@ -167,7 +231,10 @@ async function verifyStoreCredentials(database: Database, job: StoreVerification
}
}
async function cancelStaleOperation(database: Database, job: StoreVerificationJob) {
async function cancelStaleOperation(
database: Database,
job: StoreVerificationJob,
) {
await database.workspaceTransaction(job.workspaceId, (tx) =>
tx.query(
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
@@ -317,27 +384,28 @@ async function bootstrap() {
await dispatcher.dispatchBatch();
await platformDispatcher.dispatchBatch();
await platformScheduler.run();
dispatcherTimer = setInterval(
() => {
void dispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
void platformDispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'platform_outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
},
2_000,
);
dispatcherTimer = setInterval(() => {
void dispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
void platformDispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'platform_outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
}, 2_000);
platformCatalogTimer = setInterval(
() => void platformScheduler.run().catch((error: unknown) => {
console.error('platform_catalog_schedule_failed', error instanceof Error ? error.message : error);
}),
() =>
void platformScheduler.run().catch((error: unknown) => {
console.error(
'platform_catalog_schedule_failed',
error instanceof Error ? error.message : error,
);
}),
60_000,
);
void mediaUploadCleanup.run().catch((error: unknown) => {
+694
View File
@@ -0,0 +1,694 @@
import type { Database } from '@atlas/database';
import type {
AiCopyOutput,
ProductAiAutofillResult,
ProductAiSkuCopyResult,
ProductAiSourceMaterial,
} from '@atlas/contracts';
import type { OutboxJob } from './outbox-dispatcher.js';
type VariantInput = {
id: string;
display_name: string | null;
source_sku: string | null;
specification_values: unknown[];
listing: { status: string; offer_id: string | null };
title: string | null;
tags: string[];
description: string | null;
package_spec: unknown;
};
type AiInput = {
revision: { product: number; profile: number; source_snapshot: number };
platform_id: string;
product_id: string;
profile_id: string;
variant_id: string | null;
hint: string | null;
source_material: ProductAiSourceMaterial;
common: { source_title: string | null; model: string | null };
profile: {
title: string | null;
tags: string[];
description: string | null;
category: { id: string | null; path: string | null };
target_store_id: string;
};
variants: VariantInput[];
};
type Category = {
id: string;
parent_id: string | null;
name: string;
path: string;
external_type_id: string | null;
is_active: boolean;
};
type TaskRow = {
kind: 'autofill' | 'sku_copy' | string;
input_snapshot: AiInput;
};
type ModelResponse = { data: Record<string, unknown>; tokens: number };
const russianCopyRule =
'所有 title.ru、tags[].ru、description.ru 均为面向发布的俄文,严禁出现任何汉字,包括中文品牌名、系列名、颜色、尺码及括号内注释。将来源中的中文商品事实译成自然俄文;品牌、型号和货号可保留有来源依据的拉丁字母及阿拉伯数字,无法可靠翻译的中文专有名词应省略,绝不可原样复制到 ru 字段。title.zh、tags[].zh、description.zh 仍须提供中文对照。';
/** 一个 Outbox 事件只认领自己的任务;重复投递不会覆盖完成结果。 */
export async function runProductAiTask(database: Database, job: OutboxJob) {
const taskId = String(job.payload.task_id ?? '');
if (!taskId) return;
const task = await database.workspaceTransaction(
job.workspaceId,
async (tx) => {
const selected = await tx.query<TaskRow & { status: string }>(
`SELECT kind, status, input_snapshot FROM atlas.product_ai_tasks
WHERE id = $1 FOR UPDATE`,
[taskId],
);
const row = selected.rows[0];
if (!row || row.status !== 'queued') return null;
await tx.query(
`UPDATE atlas.product_ai_tasks
SET status = 'running', started_at = coalesce(started_at, now())
WHERE id = $1`,
[taskId],
);
return row;
},
);
if (!task) return;
try {
let generated: Awaited<ReturnType<typeof generateAutofill>>;
if (task.kind === 'sku_copy') {
generated = await generateSkuCopy(task.input_snapshot);
} else if (task.kind === 'autofill') {
generated = await generateAutofill(
database,
job.workspaceId,
task.input_snapshot,
);
} else {
throw new AiProviderError('AI_TASK_RETIRED');
}
const status = generated.warnings.length ? 'partial' : 'succeeded';
await database.workspaceTransaction(job.workspaceId, (tx) =>
tx.query(
`UPDATE atlas.product_ai_tasks
SET status = $2, result = $3::jsonb, model_name = $4,
completed_at = now(), error_code = NULL, error_message = NULL
WHERE id = $1 AND status = 'running'`,
[
taskId,
status,
JSON.stringify(generated.result),
process.env.DEEPSEEK_MODEL_NAME || 'deepseek-chat',
],
),
);
} catch (error) {
const code =
error instanceof AiProviderError
? error.code
: 'AI_GENERATION_FAILED';
await database.workspaceTransaction(job.workspaceId, (tx) =>
tx.query(
`UPDATE atlas.product_ai_tasks
SET status = 'failed', error_code = $2, error_message = $3,
completed_at = now()
WHERE id = $1 AND status = 'running'`,
[taskId, code, aiFailureMessage(code)],
),
);
}
}
async function generateAutofill(
database: Database,
workspaceId: string,
input: AiInput,
) {
const warnings: string[] = [];
const result: ProductAiAutofillResult = { warnings };
let categoryError: AiProviderError | null = null;
let copyError: AiProviderError | null = null;
let category: Category | null = null;
if (input.profile.category.id) {
category = await readCategory(
database,
workspaceId,
input.platform_id,
input.profile.category.id,
);
if (!category)
warnings.push('当前类目在平台目录中不可用,已跳过编号生成');
} else {
try {
const chosen = await chooseCategory(database, workspaceId, input);
category = chosen.category;
if (category)
result.category = {
category_id: category.id,
path: category.path,
};
else if (chosen.candidates.length) {
result.category_candidates = chosen.candidates.map((item) => ({
category_id: item.id,
path: item.path,
}));
warnings.push('类目有多个可能选项,请在页面确认');
} else warnings.push('类目资料不足或目录不可用,已跳过类目和编号');
} catch (error) {
categoryError = toAiProviderError(error);
warnings.push('类目推荐未完成,其他字段仍可使用');
}
}
if (category) {
const model =
input.common.model?.trim() || makeModel(category, input.product_id);
if (!input.common.model) result.model = model;
const offers = await makeOffers(database, workspaceId, input, model);
if (offers.length) result.offers = offers;
const pendingOfferCount = input.variants.filter(
(variant) =>
variant.listing.status !== 'imported' &&
!variant.listing.offer_id,
).length;
if (offers.length < pendingOfferCount)
warnings.push('部分 SKU 货号因长度或店铺冲突未生成');
}
try {
const modelResult = await deepSeekJson(
`你是跨境商品编辑助手。只用输入中的商品事实,输出 JSON;不要编造作品、角色、材料、认证、尺寸或包装。SPU 文案必须对全部 SKU 成立。${russianCopyRule}产品描述必须是易读的多行文本:首段写简短概述,空一行后用“Характеристики:”与“•”列出有证据的事实;中文使用相同段落和要点结构。不得使用 HTML 或 Markdown 表格。`,
`根据以下资料输出 JSON:{ "title": {"ru":"","zh":""}, "tags":[{"ru":"","zh":""}], "description":{"ru":"","zh":""}, "package":{"length_mm":"","width_mm":"","height_mm":"","shipping_weight_g":"","evidence":""} }。资料不足的包装请返回 null。若包装尺寸和重量来自不同来源字段,evidence 用换行连接每段逐字复制的原文;否则复制单段原文。不得翻译、改写或拼接单段原文。\n${promptFacts(input)}`,
);
const copy = validateCopy(modelResult.data);
if (Object.keys(copy).length) result.spu_copy = copy;
else warnings.push('俄文文案未通过校验');
const packageSpec = validatePackage(
modelResult.data.package,
input.source_material,
);
if (packageSpec) result.package_spec = packageSpec;
else if (hasPackageEvidence(input.source_material))
warnings.push('包装数据未通过来源核对,已跳过包装填报');
else
warnings.push(
'采集信息未包含可核对的包装尺寸和重量,已跳过包装填报',
);
} catch (error) {
copyError = toAiProviderError(error);
warnings.push('DeepSeek 文案生成失败,已保留确定性类目和编号结果');
}
if (
!result.category &&
!result.category_candidates?.length &&
!result.model &&
!result.offers?.length &&
!result.spu_copy &&
!result.package_spec
)
// 不用“没有结果”掩盖上游真实故障,页面才能给出可执行的提示。
throw (
copyError ??
categoryError ??
new AiProviderError('AI_NO_VALID_RESULT')
);
return { result, warnings };
}
async function generateSkuCopy(input: AiInput) {
const variant = input.variants.find((item) => item.id === input.variant_id);
if (!variant) throw new AiProviderError('AI_VARIANT_CHANGED');
const response = await deepSeekJson(
`你只给当前一个 SKU 生成俄文发布文案,并逐项提供中文对照。只使用来源事实和该 SKU 专属事实;不能把其他 SKU 的作品、角色、规格写到当前 SKU。输出 JSON。${russianCopyRule}产品描述必须分段:首段简短概述,空一行后以“Характеристики:”和“•”列出有证据的事实;中文采用对应的段落和要点结构。不得使用 HTML 或 Markdown 表格。`,
`输出 JSON:{ "title": {"ru":"","zh":""}, "tags":[{"ru":"","zh":""}], "description":{"ru":"","zh":""} }。\n${promptFacts(input)}\n当前 SKU:${JSON.stringify(variant)}`,
);
const copy = validateCopy(response.data);
if (!Object.keys(copy).length) throw new AiProviderError('AI_COPY_INVALID');
const result: ProductAiSkuCopyResult = {
variant_id: variant.id,
copy,
warnings: [],
};
return { result, warnings: result.warnings };
}
function promptFacts(input: AiInput) {
return JSON.stringify({
source: {
title: input.source_material.title?.slice(0, 1000),
description: input.source_material.description?.slice(0, 12000),
brand: input.source_material.brand,
categories: input.source_material.categories.slice(0, 20),
attributes: input.source_material.attributes.slice(0, 80),
},
sku_summary: input.variants.map((variant) => ({
id: variant.id,
name: variant.display_name,
specs: variant.specification_values,
})),
existing_spu_copy: input.variant_id
? {
title: input.profile.title,
tags: input.profile.tags,
description: input.profile.description?.slice(0, 4000),
}
: undefined,
hint: input.hint,
});
}
/** 模型只可从当前层给出的真实 Atlas 节点中选择 ID。 */
async function chooseCategory(
database: Database,
workspaceId: string,
input: AiInput,
) {
const categories = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<Category>(
`SELECT id, parent_id, name, path, external_type_id, is_active
FROM atlas.platform_product_categories
WHERE platform_id = $1 ORDER BY path, id`,
[input.platform_id],
),
);
const all = categories.rows;
let parent: string | null = null;
const empty = {
category: null as Category | null,
candidates: [] as Category[],
};
for (let depth = 0; depth < 8; depth += 1) {
const children = all.filter((item) => item.parent_id === parent);
if (!children.length) return empty;
const selection = await chooseCategoryAtLevel(children, input);
const picked = selection.picked;
if (!picked) return empty;
const hasChildren = all.some((item) => item.parent_id === picked.id);
if (!hasChildren) {
const alternatives = children.filter(
(item) =>
selection.alternativeIds.includes(item.id) &&
item.id !== picked.id &&
item.is_active &&
item.external_type_id &&
item.external_type_id !== '0',
);
if (alternatives.length)
return {
category: null,
candidates: [picked, ...alternatives].slice(0, 5),
};
return picked.is_active &&
picked.external_type_id &&
picked.external_type_id !== '0'
? { category: picked, candidates: [] }
: empty;
}
parent = picked.id;
}
return empty;
}
/** 大目录分段初选后再决选,避免一次把完整平台目录塞进模型上下文。 */
async function chooseCategoryAtLevel(children: Category[], input: AiInput) {
if (children.length <= 250)
return chooseCategoryCandidates(children, input);
const finalists: Category[] = [];
for (let offset = 0; offset < children.length; offset += 150) {
const selection = await chooseCategoryCandidates(
children.slice(offset, offset + 150),
input,
);
if (selection.picked) finalists.push(selection.picked);
}
if (!finalists.length) return { picked: null, alternativeIds: [] };
return chooseCategoryCandidates(finalists, input);
}
async function chooseCategoryCandidates(
candidates: Category[],
input: AiInput,
) {
if (!hasCategoryFacts(input))
return { picked: null, alternativeIds: [] as string[] };
// 让模型返回短编号而非 UUID,避免长 UUID 在 JSON 输出中被截断、改写或遗漏。
const options = candidates.map((item, index) => ({
code: index + 1,
item,
}));
const selection = await deepSeekJson(
'从给定选项中选择最合适的目标 Ozon 类目。只输出 JSON {"choice":候选编号,"alternative_choices":[候选编号]}。choice 和 alternative_choices 只能使用候选中的 code。来源资料已包含可识别的商品事实,因此 choice 必须选择一个候选编号,禁止返回 null、0、字符串或候选外编号;即使无法精确到最终叶子,也要在当前层选择最合理的上级类目。先依据商品实际形态、用途、材质和规格判断;来源站点类目仅作辅助,不能凭空猜测。',
`来源:${JSON.stringify({
title: input.source_material.title,
description: input.source_material.description?.slice(0, 4_000),
brand: input.source_material.brand,
categories: input.source_material.categories,
attributes: input.source_material.attributes.slice(0, 80),
sku_summary: input.variants.slice(0, 30).map((variant) => ({
name: variant.display_name,
specs: variant.specification_values,
})),
})}\n候选:${JSON.stringify(options.map(({ code, item }) => ({ code, name: item.name, path: item.path })))}`,
);
const choice = Number(selection.data.choice);
const picked =
options.find((option) => option.code === choice)?.item ?? null;
const alternativeIds = Array.isArray(selection.data.alternative_choices)
? selection.data.alternative_choices
.map((choice) => Number(choice))
.map(
(choice) =>
options.find((option) => option.code === choice)?.item.id,
)
.filter((id): id is string => !!id)
: [];
return { picked, alternativeIds };
}
/** 没有任何可辨别商品事实时不强迫模型在平台目录中猜测。 */
function hasCategoryFacts(input: AiInput) {
return Boolean(
input.source_material.title?.trim() ||
input.source_material.description?.trim() ||
input.source_material.categories.length ||
input.source_material.attributes.length ||
input.variants.some(
(variant) =>
variant.display_name?.trim() ||
variant.source_sku?.trim() ||
variant.specification_values.length,
),
);
}
async function readCategory(
database: Database,
workspaceId: string,
platformId: string,
categoryId: string,
) {
const row = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<Category>(
`SELECT id, parent_id, name, path, external_type_id, is_active
FROM atlas.platform_product_categories WHERE id = $1 AND platform_id = $2 AND is_active`,
[categoryId, platformId],
),
);
return row.rows[0] ?? null;
}
export function makeModel(
category: Pick<Category, 'name' | 'path'>,
productId: string,
) {
const name = `${category.path} ${category.name}`.toLowerCase();
const prefix = /手办|雕像|фигур|statue|figure/.test(name)
? 'FIG'
: /玩具|игруш|toy/.test(name)
? 'TOY'
: /服装|одеж|cloth|apparel/.test(name)
? 'APP'
: /首饰|珠宝|украшен|jewel/.test(name)
? 'JWL'
: 'PRD';
return `${prefix}-MIX-${productId.replaceAll('-', '').slice(0, 5).toUpperCase()}`;
}
async function makeOffers(
database: Database,
workspaceId: string,
input: AiInput,
model: string,
) {
const existing = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<{ offer_id: string }>(
`SELECT offer_id FROM atlas.product_variant_platform_listings
WHERE store_id = $1 AND platform_id = $2 AND offer_id IS NOT NULL`,
[input.profile.target_store_id, input.platform_id],
),
);
const occupied = new Set(existing.rows.map((row) => row.offer_id));
const offers: Array<{ variant_id: string; offer_id: string }> = [];
for (const variant of input.variants) {
if (variant.listing.status === 'imported' || variant.listing.offer_id)
continue;
const stem = `${model}-V${variant.id.replaceAll('-', '').slice(0, 6).toUpperCase()}`;
let offerId = stem;
for (
let attempt = 1;
occupied.has(offerId) && attempt < 10;
attempt += 1
)
offerId = `${stem}${attempt}`;
if (
occupied.has(offerId) ||
offerId.length > 48 ||
(offerId.match(/-/g) ?? []).length > 4
)
continue;
occupied.add(offerId);
offers.push({ variant_id: variant.id, offer_id: offerId });
}
return offers;
}
export function validateCopy(value: Record<string, unknown>): AiCopyOutput {
const output: AiCopyOutput = {};
const title = pair(value.title, 1000);
if (title) output.title = title;
const description = pair(value.description, 100000);
if (description) output.description = formatDescription(description);
if (Array.isArray(value.tags)) {
const tags = value.tags
.slice(0, 30)
.map((tag) => pair(tag, 100))
.filter((tag) => tag !== null);
if (tags.length) output.tags = tags;
}
return output;
}
/** 保留模型的段落和项目符号;对遗留的单行多句结果做最小安全分段。 */
function formatDescription(value: { ru: string; zh: string }) {
return {
ru: normalizeDescriptionLayout(value.ru, 'ru'),
zh: normalizeDescriptionLayout(value.zh, 'zh'),
};
}
function normalizeDescriptionLayout(value: string, language: 'ru' | 'zh') {
let normalized = value
.replace(/\r\n?/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
// 模型偶尔把另一种语言的固定段落标题带入中文/俄文对照,
// 仅替换无业务语义的标题,不改动用户可见的商品事实。
normalized =
language === 'zh'
? normalized.replace(
/^(?:Характеристики|Characteristics|Features)\s*:/gim,
'产品信息:',
)
: normalized.replace(
/^(?:产品信息|商品信息|产品特点)\s*[::]/gim,
'Характеристики:',
);
if (normalized.includes('\n')) return normalized;
const sentences = normalized.match(/^(.+?[.!?。!?])\s*(.+)$/su);
return sentences ? `${sentences[1]}\n\n${sentences[2].trim()}` : normalized;
}
function pair(value: unknown, max: number): { ru: string; zh: string } | null {
if (!value || typeof value !== 'object' || Array.isArray(value))
return null;
const record = value as Record<string, unknown>;
const ru = typeof record.ru === 'string' ? record.ru.trim() : '';
const zh = typeof record.zh === 'string' ? record.zh.trim() : '';
if (
!ru ||
!zh ||
ru.length > max ||
zh.length > max ||
!/[\u0400-\u04ff]/.test(ru) ||
/\p{Script=Han}/u.test(ru)
)
return null;
return { ru, zh };
}
export function validatePackage(
value: unknown,
source: ProductAiSourceMaterial,
) {
if (!value || typeof value !== 'object' || Array.isArray(value))
return null;
const record = value as Record<string, unknown>;
const evidence =
typeof record.evidence === 'string' ? record.evidence.trim() : '';
const original = packageEvidenceText(source);
const evidenceParts = evidence
.split(/\n+/)
.map((part) => part.trim())
.filter(Boolean);
if (
!evidenceParts.length ||
evidenceParts.some(
(part) => part.length < 4 || !original.includes(part),
)
)
return null;
if (!hasPackageEvidence(source)) return null;
const keys = [
'length_mm',
'width_mm',
'height_mm',
'shipping_weight_g',
] as const;
const numbers = keys.map((key) => Number(record[key]));
if (
numbers.some(
(number) =>
!Number.isFinite(number) || number <= 0 || number > 100000,
)
)
return null;
return {
length_mm: String(numbers[0]),
width_mm: String(numbers[1]),
height_mm: String(numbers[2]),
shipping_weight_g: String(numbers[3]),
evidence,
};
}
/** 1688 常把“包装尺寸”放在属性名称而非属性值;核对时两者都必须保留。 */
function packageEvidenceText(source: ProductAiSourceMaterial) {
return [
source.description ?? '',
...source.attributes.map((item) => `${item.name}: ${item.value}`),
].join('\n');
}
function hasPackageEvidence(source: ProductAiSourceMaterial) {
const packaging = /包装|包裹|外箱|package|packing|упаков/i;
return (
packaging.test(source.description ?? '') ||
source.attributes.some((item) =>
packaging.test(`${item.key} ${item.name}`),
)
);
}
class AiProviderError extends Error {
public constructor(public readonly code: string) {
super(code);
}
}
/** 将未知异常转换为对用户安全、可归因的失败类型。 */
function toAiProviderError(error: unknown) {
return error instanceof AiProviderError
? error
: new AiProviderError('DEEPSEEK_UNAVAILABLE');
}
/** 不暴露上游响应正文,但让用户知道下一步应检查什么。 */
function aiFailureMessage(code: string) {
switch (code) {
case 'DEEPSEEK_NOT_CONFIGURED':
return 'DeepSeek 未配置,请检查 Worker 的 API Key 和模型名称';
case 'DEEPSEEK_REJECTED':
return 'DeepSeek 拒绝了请求,请检查 API Key、模型名称和账户状态';
case 'DEEPSEEK_RETRYABLE':
return 'DeepSeek 当前繁忙或限流,请稍后重试';
case 'DEEPSEEK_EMPTY_RESULT':
return 'DeepSeek 未返回生成内容,请稍后重试';
case 'DEEPSEEK_INVALID_JSON':
return 'DeepSeek 返回格式异常,请稍后重试';
case 'DEEPSEEK_UNAVAILABLE':
return '暂时无法连接 DeepSeek,请稍后重试';
case 'AI_COPY_INVALID':
return 'AI 生成的文案未通过格式校验,请稍后重试';
case 'AI_TASK_RETIRED':
return '该 AI 任务类型已停用,请重新使用当前可用功能';
case 'AI_VARIANT_CHANGED':
return 'SKU 已变化,请刷新页面后重新生成';
case 'AI_NO_VALID_RESULT':
return '采集资料不足,未生成可安全回填的内容;可补充商品资料后重试';
default:
return 'AI 生成暂时失败,请稍后重试';
}
}
/** 官方 JSON 模式可能返回空内容,因此解析与网络调用分别有限次重试。 */
async function deepSeekJson(
system: string,
user: string,
): Promise<ModelResponse> {
const key = process.env.DEEPSEEK_API_KEY;
if (!key) throw new AiProviderError('DEEPSEEK_NOT_CONFIGURED');
const model = process.env.DEEPSEEK_MODEL_NAME || 'deepseek-chat';
let last: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const response = await fetch(
'https://api.deepseek.com/chat/completions',
{
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages: [
{
role: 'system',
content: `${system}\n输出必须是 JSON。`,
},
{ role: 'user', content: user },
],
response_format: { type: 'json_object' },
max_tokens: 3500,
temperature: 0.2,
}),
signal: AbortSignal.timeout(45_000),
},
);
if (!response.ok) {
if (response.status === 429 || response.status >= 500)
throw new AiProviderError('DEEPSEEK_RETRYABLE');
throw new AiProviderError('DEEPSEEK_REJECTED');
}
const body = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: { total_tokens?: number };
};
const content = body.choices?.[0]?.message?.content;
if (!content) throw new AiProviderError('DEEPSEEK_EMPTY_RESULT');
const parsed = JSON.parse(content) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
throw new AiProviderError('DEEPSEEK_INVALID_JSON');
return {
data: parsed as Record<string, unknown>,
tokens: body.usage?.total_tokens ?? 0,
};
} catch (error) {
last = error;
if (
error instanceof AiProviderError &&
error.code === 'DEEPSEEK_REJECTED'
)
break;
if (attempt < 2)
await new Promise((resolve) =>
setTimeout(resolve, 500 * (attempt + 1)),
);
}
}
throw last instanceof AiProviderError
? last
: new AiProviderError('DEEPSEEK_UNAVAILABLE');
}
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { 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');
});