listing-ai.rule-engine.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import assert from 'node:assert/strict';
  2. import { readFile } from 'node:fs/promises';
  3. import test from 'node:test';
  4. import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  5. import { canonicalHash, listingTitleVisibleCharacters, LISTING_DIMENSION_MAX, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
  6. import { normalizeJdListing } from '../src/modules/listing-ai/normalization/jd-listing.normalizer.js';
  7. import { buildListingContextIndex, LISTING_CONTEXT_VERSION } from '../src/modules/listing-ai/normalization/listing-context.enricher.js';
  8. import { listingProductScoreStatus, selectCurrentListingScore } from '../src/modules/listing-ai/scoring/score-status.js';
  9. function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSnapshot {
  10. return {
  11. id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '1001', sourceHash: 'a'.repeat(64),
  12. title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜便利店商超适用', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10', '20'],
  13. categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: 'on_shelf',
  14. price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'完整商品详情与使用场景。'.repeat(30)}</p>`, mobileHtml: `<p>${'移动端商品详情。'.repeat(30)}</p>` },
  15. descriptionStructure: { observed: true, imageCount: 8, videoCount: 1, headingCount: 4, faqCandidateCount: 5 },
  16. features: [
  17. { key: 'capacity', value: '299L 大容量' }, { key: 'efficiency', value: '一级能效' }, { key: 'cooling', value: '风冷无霜' },
  18. ],
  19. attributes: [
  20. { id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效', values: ['一级'] },
  21. ],
  22. images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: false })),
  23. skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 20, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
  24. dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: { delivery: '京东物流' }, afterService: { return7Days: true },
  25. marketing: { adword: '299L 大容量 一级能效 风冷无霜', skuShortTitles: [{ skuId: 'sku-1', value: '299L 高效冷藏' }], sellingPoints: [{ value: '299L 大容量 一级能效 风冷无霜', source: 'product_adword', fieldPath: 'productInfo.adword', skuId: null }, { value: '299L 高效冷藏', source: 'sku_short_title', fieldPath: 'skuList[].features[key=shortTitle]', skuId: 'sku-1' }] },
  26. vocEvidence: [{ id: 'voc-1', text: '容量不足和结霜是主要顾虑', sourceVersion: 'voc-v1', collectedAt: '2026-08-20T00:00:00.000Z' }],
  27. sourceModifiedAt: '2026-08-21T00:00:00.000Z', syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available', ...overrides,
  28. };
  29. }
  30. test('rule engine is deterministic for the same source and rubric', () => {
  31. const first = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
  32. const second = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
  33. assert.deepEqual(first, second);
  34. assert.equal(first.overallScore, null);
  35. assert.equal(first.knownOverallScore, 64);
  36. assert.equal(first.knownOverallMaxScore, 64);
  37. assert.equal(first.coverage.status, 'eligible');
  38. assert.equal(first.dimensions.length, 5);
  39. assert.deepEqual(Object.fromEntries(first.dimensions.map((item) => [item.dimension, item.maxScore])), LISTING_DIMENSION_MAX);
  40. });
  41. test('V7 title length counts Unicode visible characters and missing observable category is a numeric failure', () => {
  42. assert.equal(listingTitleVisibleCharacters('京东ABC😀'), 6);
  43. assert.equal(scoreListing(source({ title: '德玛仕(DEMASHI)商用电饼铛大型双面加热全自动电热大号烤饼机' })).dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.length')?.outcome, 'pass');
  44. const withoutCategory = source();
  45. delete withoutCategory.categoryContext;
  46. const result = scoreListing(withoutCategory);
  47. const title = result.dimensions.find((item) => item.dimension === 'title');
  48. assert.equal(title?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'fail');
  49. assert.equal(title?.status, 'partial');
  50. });
  51. test('compliance is independent from quality points and blocks critical redirects', () => {
  52. const result = scoreListing(source({ title: '星星 商用冷藏展示柜 联系电话:13800138000' }));
  53. assert.equal(result.compliance?.status, 'blocked');
  54. assert.equal(result.compliance?.findings.some((item) => item.ruleId === 'compliance.external_redirect'), true);
  55. });
  56. test('missing detail is blocked instead of receiving a zero quality score', () => {
  57. const result = scoreListing(source({
  58. title: null, features: [], images: [], attributes: [], skus: [], descriptions: { desktopHtml: null, mobileHtml: null }, detailStatus: 'empty',
  59. }));
  60. assert.equal(result.coverage.status, 'blocked');
  61. assert.equal(result.overallScore, null);
  62. assert.ok(result.dimensions.every((dimension) => dimension.score === null));
  63. });
  64. test('JD transport flags are not misclassified as duplicate selling points', () => {
  65. const result = scoreListing(source({
  66. features: [
  67. { key: 'isPayFirst', value: '1' },
  68. { key: 'is7ToReturn', value: '1' },
  69. { key: 'popsfkc', value: '0' },
  70. { key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' },
  71. { key: 'model', value: 'BC-299' },
  72. ],
  73. }));
  74. const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
  75. const duplicateRule = sellingPoints?.evidence.find((item) => item.ruleId === 'selling_points.exact_dedup');
  76. assert.equal(result.rubricVersion, 'listing-jd-v7');
  77. assert.equal(duplicateRule?.outcome, 'pass');
  78. assert.equal(duplicateRule?.delta, 0);
  79. });
  80. test('canonical source hash ignores object key insertion order', () => {
  81. assert.equal(canonicalHash({ a: 1, b: { c: 2 } }), canonicalHash({ b: { c: 2 }, a: 1 }));
  82. });
  83. test('JD adword and SKU shortTitle are normalized as attributed marketing points', () => {
  84. const normalized = normalizeJdListing({
  85. workspaceId: 'demashi', shopId: 'shop', row: { productId: '2001' },
  86. detail: { productInfo: { productId: '2001', adword: '商用大容量', features: [] }, skuList: [
  87. { skuId: 'sku-1', features: [{ key: 'shortTitle', value: '70L 高效供水' }] },
  88. { skuId: 'sku-2', features: [{ key: 'shortTitle', value: '70L 高效供水' }] },
  89. ] },
  90. syncedAt: '2026-08-24T00:00:00.000Z',
  91. });
  92. assert.equal(normalized.normalizerVersion, 'jd-listing-v5');
  93. assert.deepEqual(normalized.marketing?.sellingPoints.map((item) => [item.source, item.value]), [
  94. ['product_adword', '商用大容量'], ['sku_short_title', '70L 高效供水'],
  95. ]);
  96. });
  97. test('composite JD category labels publish independently matchable core terms', () => {
  98. const result = scoreListing(source({
  99. title: '星星 商用电饼铛 大号双面加热',
  100. categoryContext: { names: ['商用电饼铛/煎包锅贴机/烤饼机'], coreTerms: ['商用电饼铛', '煎包锅贴机', '烤饼机'], aliases: ['商用电饼铛', '煎包锅贴机', '烤饼机'], requiredSpecificationNames: ['品牌'], qualificationNames: [], ruleVersion: 'jd-category-rules-2026-08-v2' },
  101. }));
  102. assert.equal(result.dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'pass');
  103. });
  104. test('V6 category position starts after a confirmed bilingual leading brand', () => {
  105. const result = scoreListing(source({
  106. title: '德玛仕(DEMASHI)商用电饼铛大型双面加热全自动电热大号烤饼机',
  107. titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' },
  108. categoryContext: { names: ['商用电饼铛/煎包锅贴机/烤饼机'], coreTerms: ['商用电饼铛'], aliases: [], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'legacy' },
  109. }));
  110. assert.equal(result.dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'pass');
  111. });
  112. test('normalizer preserves image groups and uses only the default group primary as product primary', () => {
  113. const normalized = normalizeJdListing({
  114. workspaceId: 'demashi', shopId: 'shop', row: { productId: 'image-groups' },
  115. detail: { productInfo: { productId: 'image-groups' }, material: { mainImages: [
  116. { uuid: '0000000000', imageInfoList: [{ imgUrl: 'default-1.jpg', orderSort: 1, primaryFlag: true }, { imgUrl: 'default-2.jpg', orderSort: 2, primaryFlag: false }] },
  117. { uuid: 'sku-1', imageInfoList: [{ imgUrl: 'sku-1.jpg', orderSort: 1, primaryFlag: true }] },
  118. ] } },
  119. });
  120. assert.equal(normalized.images.filter((item) => item.isPrimary === true).length, 1);
  121. assert.equal(normalized.images[0]?.groupId, '0000000000');
  122. assert.equal(normalized.images[0]?.primarySource, 'authoritative_field');
  123. assert.equal(scoreListing(normalized).dimensions.find((item) => item.dimension === 'images')?.evidence.find((item) => item.ruleId === 'images.primary')?.outcome, 'pass');
  124. });
  125. test('ambiguous image groups produce a numeric primary failure instead of a dynamic denominator', () => {
  126. const normalized = normalizeJdListing({
  127. workspaceId: 'demashi', shopId: 'shop', row: { productId: 'ambiguous-groups' },
  128. detail: { productInfo: { productId: 'ambiguous-groups' }, material: { mainImages: [
  129. { uuid: 'sku-1', imageInfoList: [{ imgUrl: 'sku-1.jpg', orderSort: 1, primaryFlag: true }] },
  130. { uuid: 'sku-2', imageInfoList: [{ imgUrl: 'sku-2.jpg', orderSort: 1, primaryFlag: true }] },
  131. ] } },
  132. });
  133. const result = scoreListing(normalized);
  134. assert.equal(normalized.images.length, 0);
  135. assert.equal(result.dimensions.find((item) => item.dimension === 'images')?.evidence.find((item) => item.ruleId === 'images.primary')?.outcome, 'fail');
  136. assert.deepEqual(result.unknownCriteria, []);
  137. });
  138. test('a result without an overall score is classified as partial instead of disappearing between filters', () => {
  139. const input = source();
  140. const result = scoreListing(input);
  141. const partial = { ...result, overallScore: null };
  142. assert.equal(listingProductScoreStatus(input, partial), 'partial');
  143. });
  144. test('the presentation gate never falls back to a legacy score version', () => {
  145. const input=source();
  146. const current=scoreListing(input);
  147. const legacy={...current,rubricVersion:'listing-jd-v4',overallScore:88,knownOverallScore:88,knownOverallMaxScore:100};
  148. assert.equal(selectCurrentListingScore([legacy],input.sourceHash).displayScore,null);
  149. assert.equal(selectCurrentListingScore([current],input.sourceHash).displayScore?.rubricVersion,'listing-jd-v7');
  150. });
  151. test('V7 migration creates current slots and physically drops score history', async () => {
  152. const migration = await readFile(new URL('../migrations/012_listing_remove_score_history.sql', import.meta.url), 'utf8');
  153. assert.match(migration, /CREATE TABLE IF NOT EXISTS voc\.listing_current_score/);
  154. assert.match(migration, /UNIQUE \(workspace_id, product_id, slot\)/);
  155. assert.match(migration, /DROP TABLE voc\.listing_score_result/);
  156. assert.match(migration, /DROP COLUMN IF EXISTS score_result_public_id/);
  157. assert.match(migration, /DROP COLUMN IF EXISTS base_score_result_public_id/);
  158. });
  159. test('listing context joins SKU categories and versioned category VOC evidence', () => {
  160. const index = buildListingContextIndex(
  161. [{ productId: 'sku-1', category3: '商用冰箱', title: 'SKU 商品' }],
  162. [{ competitorProductId: 'competitor-1', category: '商用冰箱' }],
  163. [{ productId: 'competitor-1', reviewKey: 'review-1', rating: 2, content: '容易结霜,容量偏小', reviewDate: '2026-08-20T00:00:00.000Z' }],
  164. );
  165. const enriched = index.enrich(source(), '2026-08-24T00:00:00.000Z');
  166. assert.equal(enriched.contextVersion, LISTING_CONTEXT_VERSION);
  167. assert.deepEqual(enriched.categoryContext?.coreTerms, ['商用冰箱']);
  168. assert.equal(enriched.categoryContext?.ruleVersion, 'jd-category-rules-2026-08-v3');
  169. assert.equal(enriched.vocEvidence?.[0]?.text, '容易结霜,容量偏小');
  170. assert.notEqual(enriched.sourceHash, source().sourceHash);
  171. });