import 'dotenv/config'; import { createHash, randomUUID } from 'node:crypto'; import { loadConfig } from '../src/config/env.js'; import { ParseRestClient } from '../src/db/parse-rest.client.js'; import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js'; import type { ListingDimension, ListingDimensionScore, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js'; import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js'; import { LISTING_AI_RUBRIC_VERSION, LISTING_AI_PROMPT_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js'; import { canonicalHash, listingCompliance, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js'; const MODEL = 'listing-v7-demo-simulation'; const EXPECTED = 625; const TARGET_AVERAGE = 87.3; const MINIMUM_SCORE = 73; const MAXIMUM_SCORE = 100; const args = new Map(process.argv.slice(2).map((arg) => { const [key, ...rest] = arg.split('='); return [key!, rest.join('=') || 'true']; })); const definitions: Record> = { title: [ { title: '标题信息完整、便于识别商品', fieldPath: 'title', max: 10 }, { title: '品牌、品类和关键属性表达清楚', fieldPath: 'brand', max: 10 }, { title: '标题层级清晰、阅读流畅', fieldPath: 'title', max: 10 }, ], selling_points: [ { title: '核心卖点覆盖充分', fieldPath: 'marketing', max: 9 }, { title: '卖点具体并有商品信息支撑', fieldPath: 'attributes', max: 8 }, { title: '各规格卖点表达保持一致', fieldPath: 'skus', max: 8 }, ], images: [ { title: '商品图片数量满足展示需要', fieldPath: 'images', max: 7 }, { title: '主图位置和图片顺序规范', fieldPath: 'images', max: 7 }, { title: '图片链接与规格图片结构完整', fieldPath: 'images', max: 6 }, ], description: [ { title: '商品详情素材完整', fieldPath: 'descriptions', max: 5 }, { title: '电脑端与移动端详情结构稳定', fieldPath: 'descriptionStructure', max: 5 }, { title: '详情素材顺序清楚且无明显重复', fieldPath: 'descriptions', max: 5 }, ], specifications: [ { title: '商品属性填写完整', fieldPath: 'attributes', max: 4 }, { title: '商品规格信息一致', fieldPath: 'skus', max: 3 }, { title: '尺寸、配送和售后信息可用', fieldPath: 'dimensions', max: 3 }, ], }; function hashNumber(value: string): number { return Number.parseInt(createHash('sha256').update(value).digest('hex').slice(0, 8), 16); } function uniform(value: string, salt: string): number { return (hashNumber(`${salt}|${value}`) + 1) / 0x1_0000_0001; } function exactTargets(sources: ListingSourceSnapshot[]): Map { const rows = sources.map((source) => { const u1 = Math.max(Number.EPSILON, uniform(source.productId, 'normal-u1')); const u2 = uniform(source.productId, 'normal-u2'); const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); const score = Math.round(Math.min(99, Math.max(74, TARGET_AVERAGE + z * 5.2)) * 2) / 2; return { productId: source.productId, score, rank: hashNumber(`rank|${source.productId}`) }; }).sort((a, b) => a.rank - b.rank); rows[0]!.score = MINIMUM_SCORE; rows[1]!.score = MAXIMUM_SCORE; let deltaSteps = Math.round((TARGET_AVERAGE * rows.length - rows.reduce((sum, row) => sum + row.score, 0)) * 2); const adjustable = rows.slice(2).sort((a, b) => Math.abs(a.score - TARGET_AVERAGE) - Math.abs(b.score - TARGET_AVERAGE) || a.rank - b.rank); for (let pass = 0; deltaSteps !== 0 && pass < 100; pass += 1) { for (const row of adjustable) { if (deltaSteps > 0 && row.score < 99) { row.score += 0.5; deltaSteps -= 1; } else if (deltaSteps < 0 && row.score > 74) { row.score -= 0.5; deltaSteps += 1; } if (!deltaSteps) break; } } if (deltaSteps) throw new Error(`simulation_average_adjustment_failed:${deltaSteps}`); if (rows.filter((row) => row.score === MINIMUM_SCORE).length !== 1 || rows.filter((row) => row.score === MAXIMUM_SCORE).length !== 1) throw new Error('simulation_extreme_count_invalid'); return new Map(rows.map((row) => [row.productId, row.score])); } function dimensionScores(total: number): Record { const maxima: Record = { title: 30, selling_points: 25, images: 20, description: 15, specifications: 10 }; const keys = Object.keys(maxima) as ListingDimension[]; const output = Object.fromEntries(keys.map((key) => [key, Math.round(total * maxima[key] / 100 * 2) / 2])) as Record; let delta = Math.round((total - keys.reduce((sum, key) => sum + output[key], 0)) * 2); for (const key of keys) { while (delta > 0 && output[key] < maxima[key]) { output[key] += 0.5; delta -= 1; } while (delta < 0 && output[key] > 0) { output[key] -= 0.5; delta += 1; } } return output; } function buildDimension(dimension: ListingDimension, score: number): ListingDimensionScore { const rows = definitions[dimension]; const maxScore = rows.reduce((sum, row) => sum + row.max, 0); const allocated = rows.map((row) => Math.round(score * row.max / maxScore * 2) / 2); let delta = Math.round((score - allocated.reduce((sum, value) => sum + value, 0)) * 2); for (let index = 0; delta !== 0; index = (index + 1) % rows.length) { if (delta > 0 && allocated[index]! < rows[index]!.max) { allocated[index]! += 0.5; delta -= 1; } else if (delta < 0 && allocated[index]! > 0) { allocated[index]! -= 0.5; delta += 1; } } return { dimension, score, maxScore, knownScore: score, knownMaxScore: maxScore, coverage: 1, status: 'scored', suggestions: [], evidence: rows.map((row, index) => { const pointsAwarded = allocated[index]!; const level = pointsAwarded / row.max >= 0.9 ? 'strong' : pointsAwarded / row.max >= 0.75 ? 'pass' : 'weak'; return { ruleId: `simulation.${dimension}.${index + 1}`, fieldPath: row.fieldPath, outcome: level === 'weak' ? 'fail' : 'pass', delta: pointsAwarded - row.max, message: `${row.title}:本次为展示用模拟评估,依据当前商品资料生成。`, source: 'ai', level, pointsAwarded, maxPoints: row.max, confidence: 0.85, citations: [], }; }), }; } function buildScore(source: ListingSourceSnapshot, target: number): ListingScoreResult { const baseline = scoreListing(source); const scores = dimensionScores(target); const dimensions = (Object.keys(scores) as ListingDimension[]).map((dimension) => buildDimension(dimension, scores[dimension])); const createdAt = new Date().toISOString(); return { ...baseline, id: randomUUID(), rubricVersion: LISTING_AI_RUBRIC_VERSION, overallScore: target, knownOverallScore: target, knownOverallMaxScore: 100, coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions, compliance: listingCompliance(source), unknownCriteria: [], aiStatus: 'completed', aiSuggestions: dimensions.flatMap((dimension) => dimension.suggestions), aiCandidate: { title: source.title, sellingPoints: source.marketing?.sellingPoints.map((item) => item.value) ?? [], descriptionHtml: source.descriptions.mobileHtml ?? source.descriptions.desktopHtml, specifications: source.attributes, imageUrls: source.images.map((item) => item.url) }, model: MODEL, promptVersion: LISTING_AI_PROMPT_VERSION, scoreKind: 'hybrid_ai', baselineOverallScore: baseline.knownOverallScore ?? null, aiConfidence: 0.85, inputFingerprint: canonicalHash({ sourceHash: source.sourceHash, rubricVersion: LISTING_AI_RUBRIC_VERSION, model: MODEL, target }), executionKey: `simulation|${source.productId}`, requestedBy: 'listing-demo-simulation', rescorePolicy: 'force', createdAt, }; } async function concurrent(items: T[], worker: (item: T) => Promise, concurrency = 10): Promise { let cursor = 0; await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (cursor < items.length) { const item = items[cursor++]!; await worker(item); } })); } async function main() { if (args.get('--apply') !== 'true') throw new Error('apply_required:rerun_with_--apply=true'); const config = loadConfig(); if (config.storageDriver !== 'parse_rest') throw new Error('simulation_publisher_requires_parse_rest'); const workspaceId = args.get('--workspace') ?? config.auth.defaultWorkspaceId; const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs }); await ensureListingParseSchemas(client); const repository = new ParseRestListingAiRepository(client); const sources = await repository.listAllSources(workspaceId, 'jd'); if (sources.length !== EXPECTED) throw new Error(`simulation_source_count_mismatch:${sources.length}:${EXPECTED}`); const sourceIds = new Set(sources.map((source) => source.productId)); const existing = await client.findAll<{ productId: string }>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId }); const obsolete = existing.filter((row) => !sourceIds.has(row.productId)); await concurrent(obsolete, (row) => client.delete(VOC_PARSE_CLASSES.listingCurrentScore, row.objectId)); const targets = exactTargets(sources); await concurrent(sources, async (source) => { await repository.upsertCurrentScore(buildScore(source, targets.get(source.productId)!)); }); const scores = [...targets.values()]; const histogram = scores.reduce>((output, score) => { const bucket = score < 75 ? '73-74.5' : score < 80 ? '75-79.5' : score < 85 ? '80-84.5' : score < 90 ? '85-89.5' : score < 95 ? '90-94.5' : '95-100'; output[bucket] = (output[bucket] ?? 0) + 1; return output; }, {}); console.log(JSON.stringify({ mode: 'applied', workspaceId, products: sources.length, obsoleteScoresDeleted: obsolete.length, model: MODEL, simulated: true, minimum: Math.min(...scores), maximum: Math.max(...scores), minimumCount: scores.filter((score) => score === MINIMUM_SCORE).length, maximumCount: scores.filter((score) => score === MAXIMUM_SCORE).length, average: scores.reduce((sum, score) => sum + score, 0) / scores.length, histogram }, null, 2)); } main().catch((error) => { console.error(`[publish-listing-simulated-scores] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });