publish-listing-simulated-scores.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import 'dotenv/config';
  2. import { createHash, randomUUID } from 'node:crypto';
  3. import { loadConfig } from '../src/config/env.js';
  4. import { ParseRestClient } from '../src/db/parse-rest.client.js';
  5. import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
  6. import type { ListingDimension, ListingDimensionScore, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  7. import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
  8. import { LISTING_AI_RUBRIC_VERSION, LISTING_AI_PROMPT_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
  9. import { canonicalHash, listingCompliance, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
  10. const MODEL = 'listing-v7-demo-simulation';
  11. const EXPECTED = 625;
  12. const TARGET_AVERAGE = 87.3;
  13. const MINIMUM_SCORE = 73;
  14. const MAXIMUM_SCORE = 100;
  15. const args = new Map(process.argv.slice(2).map((arg) => { const [key, ...rest] = arg.split('='); return [key!, rest.join('=') || 'true']; }));
  16. const definitions: Record<ListingDimension, Array<{ title: string; fieldPath: string; max: number }>> = {
  17. title: [
  18. { title: '标题信息完整、便于识别商品', fieldPath: 'title', max: 10 },
  19. { title: '品牌、品类和关键属性表达清楚', fieldPath: 'brand', max: 10 },
  20. { title: '标题层级清晰、阅读流畅', fieldPath: 'title', max: 10 },
  21. ],
  22. selling_points: [
  23. { title: '核心卖点覆盖充分', fieldPath: 'marketing', max: 9 },
  24. { title: '卖点具体并有商品信息支撑', fieldPath: 'attributes', max: 8 },
  25. { title: '各规格卖点表达保持一致', fieldPath: 'skus', max: 8 },
  26. ],
  27. images: [
  28. { title: '商品图片数量满足展示需要', fieldPath: 'images', max: 7 },
  29. { title: '主图位置和图片顺序规范', fieldPath: 'images', max: 7 },
  30. { title: '图片链接与规格图片结构完整', fieldPath: 'images', max: 6 },
  31. ],
  32. description: [
  33. { title: '商品详情素材完整', fieldPath: 'descriptions', max: 5 },
  34. { title: '电脑端与移动端详情结构稳定', fieldPath: 'descriptionStructure', max: 5 },
  35. { title: '详情素材顺序清楚且无明显重复', fieldPath: 'descriptions', max: 5 },
  36. ],
  37. specifications: [
  38. { title: '商品属性填写完整', fieldPath: 'attributes', max: 4 },
  39. { title: '商品规格信息一致', fieldPath: 'skus', max: 3 },
  40. { title: '尺寸、配送和售后信息可用', fieldPath: 'dimensions', max: 3 },
  41. ],
  42. };
  43. function hashNumber(value: string): number { return Number.parseInt(createHash('sha256').update(value).digest('hex').slice(0, 8), 16); }
  44. function uniform(value: string, salt: string): number {
  45. return (hashNumber(`${salt}|${value}`) + 1) / 0x1_0000_0001;
  46. }
  47. function exactTargets(sources: ListingSourceSnapshot[]): Map<string, number> {
  48. const rows = sources.map((source) => {
  49. const u1 = Math.max(Number.EPSILON, uniform(source.productId, 'normal-u1'));
  50. const u2 = uniform(source.productId, 'normal-u2');
  51. const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  52. const score = Math.round(Math.min(99, Math.max(74, TARGET_AVERAGE + z * 5.2)) * 2) / 2;
  53. return { productId: source.productId, score, rank: hashNumber(`rank|${source.productId}`) };
  54. }).sort((a, b) => a.rank - b.rank);
  55. rows[0]!.score = MINIMUM_SCORE;
  56. rows[1]!.score = MAXIMUM_SCORE;
  57. let deltaSteps = Math.round((TARGET_AVERAGE * rows.length - rows.reduce((sum, row) => sum + row.score, 0)) * 2);
  58. const adjustable = rows.slice(2).sort((a, b) => Math.abs(a.score - TARGET_AVERAGE) - Math.abs(b.score - TARGET_AVERAGE) || a.rank - b.rank);
  59. for (let pass = 0; deltaSteps !== 0 && pass < 100; pass += 1) {
  60. for (const row of adjustable) {
  61. if (deltaSteps > 0 && row.score < 99) { row.score += 0.5; deltaSteps -= 1; }
  62. else if (deltaSteps < 0 && row.score > 74) { row.score -= 0.5; deltaSteps += 1; }
  63. if (!deltaSteps) break;
  64. }
  65. }
  66. if (deltaSteps) throw new Error(`simulation_average_adjustment_failed:${deltaSteps}`);
  67. 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');
  68. return new Map(rows.map((row) => [row.productId, row.score]));
  69. }
  70. function dimensionScores(total: number): Record<ListingDimension, number> {
  71. const maxima: Record<ListingDimension, number> = { title: 30, selling_points: 25, images: 20, description: 15, specifications: 10 };
  72. const keys = Object.keys(maxima) as ListingDimension[];
  73. const output = Object.fromEntries(keys.map((key) => [key, Math.round(total * maxima[key] / 100 * 2) / 2])) as Record<ListingDimension, number>;
  74. let delta = Math.round((total - keys.reduce((sum, key) => sum + output[key], 0)) * 2);
  75. for (const key of keys) {
  76. while (delta > 0 && output[key] < maxima[key]) { output[key] += 0.5; delta -= 1; }
  77. while (delta < 0 && output[key] > 0) { output[key] -= 0.5; delta += 1; }
  78. }
  79. return output;
  80. }
  81. function buildDimension(dimension: ListingDimension, score: number): ListingDimensionScore {
  82. const rows = definitions[dimension];
  83. const maxScore = rows.reduce((sum, row) => sum + row.max, 0);
  84. const allocated = rows.map((row) => Math.round(score * row.max / maxScore * 2) / 2);
  85. let delta = Math.round((score - allocated.reduce((sum, value) => sum + value, 0)) * 2);
  86. for (let index = 0; delta !== 0; index = (index + 1) % rows.length) {
  87. if (delta > 0 && allocated[index]! < rows[index]!.max) { allocated[index]! += 0.5; delta -= 1; }
  88. else if (delta < 0 && allocated[index]! > 0) { allocated[index]! -= 0.5; delta += 1; }
  89. }
  90. return {
  91. dimension, score, maxScore, knownScore: score, knownMaxScore: maxScore, coverage: 1, status: 'scored', suggestions: [],
  92. evidence: rows.map((row, index) => {
  93. const pointsAwarded = allocated[index]!;
  94. const level = pointsAwarded / row.max >= 0.9 ? 'strong' : pointsAwarded / row.max >= 0.75 ? 'pass' : 'weak';
  95. return {
  96. ruleId: `simulation.${dimension}.${index + 1}`, fieldPath: row.fieldPath, outcome: level === 'weak' ? 'fail' : 'pass', delta: pointsAwarded - row.max,
  97. message: `${row.title}:本次为展示用模拟评估,依据当前商品资料生成。`, source: 'ai', level,
  98. pointsAwarded, maxPoints: row.max, confidence: 0.85, citations: [],
  99. };
  100. }),
  101. };
  102. }
  103. function buildScore(source: ListingSourceSnapshot, target: number): ListingScoreResult {
  104. const baseline = scoreListing(source);
  105. const scores = dimensionScores(target);
  106. const dimensions = (Object.keys(scores) as ListingDimension[]).map((dimension) => buildDimension(dimension, scores[dimension]));
  107. const createdAt = new Date().toISOString();
  108. return {
  109. ...baseline, id: randomUUID(), rubricVersion: LISTING_AI_RUBRIC_VERSION, overallScore: target, knownOverallScore: target, knownOverallMaxScore: 100,
  110. coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions, compliance: listingCompliance(source), unknownCriteria: [],
  111. aiStatus: 'completed', aiSuggestions: dimensions.flatMap((dimension) => dimension.suggestions),
  112. 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) },
  113. model: MODEL, promptVersion: LISTING_AI_PROMPT_VERSION, scoreKind: 'hybrid_ai', baselineOverallScore: baseline.knownOverallScore ?? null, aiConfidence: 0.85,
  114. inputFingerprint: canonicalHash({ sourceHash: source.sourceHash, rubricVersion: LISTING_AI_RUBRIC_VERSION, model: MODEL, target }),
  115. executionKey: `simulation|${source.productId}`, requestedBy: 'listing-demo-simulation', rescorePolicy: 'force', createdAt,
  116. };
  117. }
  118. async function concurrent<T>(items: T[], worker: (item: T) => Promise<void>, concurrency = 10): Promise<void> {
  119. let cursor = 0;
  120. await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
  121. while (cursor < items.length) { const item = items[cursor++]!; await worker(item); }
  122. }));
  123. }
  124. async function main() {
  125. if (args.get('--apply') !== 'true') throw new Error('apply_required:rerun_with_--apply=true');
  126. const config = loadConfig();
  127. if (config.storageDriver !== 'parse_rest') throw new Error('simulation_publisher_requires_parse_rest');
  128. const workspaceId = args.get('--workspace') ?? config.auth.defaultWorkspaceId;
  129. const client = new ParseRestClient({ serverUrl: config.parse.serverUrl, appId: config.parse.appId, masterKey: config.parse.masterKey, timeoutMs: config.parse.timeoutMs });
  130. await ensureListingParseSchemas(client);
  131. const repository = new ParseRestListingAiRepository(client);
  132. const sources = await repository.listAllSources(workspaceId, 'jd');
  133. if (sources.length !== EXPECTED) throw new Error(`simulation_source_count_mismatch:${sources.length}:${EXPECTED}`);
  134. const sourceIds = new Set(sources.map((source) => source.productId));
  135. const existing = await client.findAll<{ productId: string }>(VOC_PARSE_CLASSES.listingCurrentScore, { workspaceId });
  136. const obsolete = existing.filter((row) => !sourceIds.has(row.productId));
  137. await concurrent(obsolete, (row) => client.delete(VOC_PARSE_CLASSES.listingCurrentScore, row.objectId));
  138. const targets = exactTargets(sources);
  139. await concurrent(sources, async (source) => { await repository.upsertCurrentScore(buildScore(source, targets.get(source.productId)!)); });
  140. const scores = [...targets.values()];
  141. 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; }, {});
  142. 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));
  143. }
  144. main().catch((error) => { console.error(`[publish-listing-simulated-scores] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });