feat(ai-edit): 开发 AI 填报功能
This commit is contained in:
+105
-37
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user