| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155 |
- 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<ListingDimension, Array<{ title: string; fieldPath: string; max: number }>> = {
- 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<string, number> {
- 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<ListingDimension, number> {
- const maxima: Record<ListingDimension, number> = { 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<ListingDimension, number>;
- 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<T>(items: T[], worker: (item: T) => Promise<void>, concurrency = 10): Promise<void> {
- 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<Record<string, number>>((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; });
|