| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- import assert from 'node:assert/strict';
- import { readFile } from 'node:fs/promises';
- import test from 'node:test';
- import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
- import { canonicalHash, listingTitleVisibleCharacters, LISTING_DIMENSION_MAX, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
- import { normalizeJdListing } from '../src/modules/listing-ai/normalization/jd-listing.normalizer.js';
- import { buildListingContextIndex, LISTING_CONTEXT_VERSION } from '../src/modules/listing-ai/normalization/listing-context.enricher.js';
- import { listingProductScoreStatus, selectCurrentListingScore } from '../src/modules/listing-ai/scoring/score-status.js';
- function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSnapshot {
- return {
- id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '1001', sourceHash: 'a'.repeat(64),
- title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜便利店商超适用', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10', '20'],
- categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: 'on_shelf',
- price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'完整商品详情与使用场景。'.repeat(30)}</p>`, mobileHtml: `<p>${'移动端商品详情。'.repeat(30)}</p>` },
- descriptionStructure: { observed: true, imageCount: 8, videoCount: 1, headingCount: 4, faqCandidateCount: 5 },
- features: [
- { key: 'capacity', value: '299L 大容量' }, { key: 'efficiency', value: '一级能效' }, { key: 'cooling', value: '风冷无霜' },
- ],
- attributes: [
- { id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效', values: ['一级'] },
- ],
- images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: false })),
- skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 20, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
- dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: { delivery: '京东物流' }, afterService: { return7Days: true },
- 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' }] },
- vocEvidence: [{ id: 'voc-1', text: '容量不足和结霜是主要顾虑', sourceVersion: 'voc-v1', collectedAt: '2026-08-20T00:00:00.000Z' }],
- sourceModifiedAt: '2026-08-21T00:00:00.000Z', syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available', ...overrides,
- };
- }
- test('rule engine is deterministic for the same source and rubric', () => {
- const first = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
- const second = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
- assert.deepEqual(first, second);
- assert.equal(first.overallScore, null);
- assert.equal(first.knownOverallScore, 64);
- assert.equal(first.knownOverallMaxScore, 64);
- assert.equal(first.coverage.status, 'eligible');
- assert.equal(first.dimensions.length, 5);
- assert.deepEqual(Object.fromEntries(first.dimensions.map((item) => [item.dimension, item.maxScore])), LISTING_DIMENSION_MAX);
- });
- test('V7 title length counts Unicode visible characters and missing observable category is a numeric failure', () => {
- assert.equal(listingTitleVisibleCharacters('京东ABC😀'), 6);
- assert.equal(scoreListing(source({ title: '德玛仕(DEMASHI)商用电饼铛大型双面加热全自动电热大号烤饼机' })).dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.length')?.outcome, 'pass');
- const withoutCategory = source();
- delete withoutCategory.categoryContext;
- const result = scoreListing(withoutCategory);
- const title = result.dimensions.find((item) => item.dimension === 'title');
- assert.equal(title?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'fail');
- assert.equal(title?.status, 'partial');
- });
- test('compliance is independent from quality points and blocks critical redirects', () => {
- const result = scoreListing(source({ title: '星星 商用冷藏展示柜 联系电话:13800138000' }));
- assert.equal(result.compliance?.status, 'blocked');
- assert.equal(result.compliance?.findings.some((item) => item.ruleId === 'compliance.external_redirect'), true);
- });
- test('missing detail is blocked instead of receiving a zero quality score', () => {
- const result = scoreListing(source({
- title: null, features: [], images: [], attributes: [], skus: [], descriptions: { desktopHtml: null, mobileHtml: null }, detailStatus: 'empty',
- }));
- assert.equal(result.coverage.status, 'blocked');
- assert.equal(result.overallScore, null);
- assert.ok(result.dimensions.every((dimension) => dimension.score === null));
- });
- test('JD transport flags are not misclassified as duplicate selling points', () => {
- const result = scoreListing(source({
- features: [
- { key: 'isPayFirst', value: '1' },
- { key: 'is7ToReturn', value: '1' },
- { key: 'popsfkc', value: '0' },
- { key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' },
- { key: 'model', value: 'BC-299' },
- ],
- }));
- const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
- const duplicateRule = sellingPoints?.evidence.find((item) => item.ruleId === 'selling_points.exact_dedup');
- assert.equal(result.rubricVersion, 'listing-jd-v7');
- assert.equal(duplicateRule?.outcome, 'pass');
- assert.equal(duplicateRule?.delta, 0);
- });
- test('canonical source hash ignores object key insertion order', () => {
- assert.equal(canonicalHash({ a: 1, b: { c: 2 } }), canonicalHash({ b: { c: 2 }, a: 1 }));
- });
- test('JD adword and SKU shortTitle are normalized as attributed marketing points', () => {
- const normalized = normalizeJdListing({
- workspaceId: 'demashi', shopId: 'shop', row: { productId: '2001' },
- detail: { productInfo: { productId: '2001', adword: '商用大容量', features: [] }, skuList: [
- { skuId: 'sku-1', features: [{ key: 'shortTitle', value: '70L 高效供水' }] },
- { skuId: 'sku-2', features: [{ key: 'shortTitle', value: '70L 高效供水' }] },
- ] },
- syncedAt: '2026-08-24T00:00:00.000Z',
- });
- assert.equal(normalized.normalizerVersion, 'jd-listing-v5');
- assert.deepEqual(normalized.marketing?.sellingPoints.map((item) => [item.source, item.value]), [
- ['product_adword', '商用大容量'], ['sku_short_title', '70L 高效供水'],
- ]);
- });
- test('composite JD category labels publish independently matchable core terms', () => {
- const result = scoreListing(source({
- title: '星星 商用电饼铛 大号双面加热',
- categoryContext: { names: ['商用电饼铛/煎包锅贴机/烤饼机'], coreTerms: ['商用电饼铛', '煎包锅贴机', '烤饼机'], aliases: ['商用电饼铛', '煎包锅贴机', '烤饼机'], requiredSpecificationNames: ['品牌'], qualificationNames: [], ruleVersion: 'jd-category-rules-2026-08-v2' },
- }));
- assert.equal(result.dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'pass');
- });
- test('V6 category position starts after a confirmed bilingual leading brand', () => {
- const result = scoreListing(source({
- title: '德玛仕(DEMASHI)商用电饼铛大型双面加热全自动电热大号烤饼机',
- titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' },
- categoryContext: { names: ['商用电饼铛/煎包锅贴机/烤饼机'], coreTerms: ['商用电饼铛'], aliases: [], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'legacy' },
- }));
- assert.equal(result.dimensions.find((item) => item.dimension === 'title')?.evidence.find((item) => item.ruleId === 'title.category_in_first_15')?.outcome, 'pass');
- });
- test('normalizer preserves image groups and uses only the default group primary as product primary', () => {
- const normalized = normalizeJdListing({
- workspaceId: 'demashi', shopId: 'shop', row: { productId: 'image-groups' },
- detail: { productInfo: { productId: 'image-groups' }, material: { mainImages: [
- { uuid: '0000000000', imageInfoList: [{ imgUrl: 'default-1.jpg', orderSort: 1, primaryFlag: true }, { imgUrl: 'default-2.jpg', orderSort: 2, primaryFlag: false }] },
- { uuid: 'sku-1', imageInfoList: [{ imgUrl: 'sku-1.jpg', orderSort: 1, primaryFlag: true }] },
- ] } },
- });
- assert.equal(normalized.images.filter((item) => item.isPrimary === true).length, 1);
- assert.equal(normalized.images[0]?.groupId, '0000000000');
- assert.equal(normalized.images[0]?.primarySource, 'authoritative_field');
- assert.equal(scoreListing(normalized).dimensions.find((item) => item.dimension === 'images')?.evidence.find((item) => item.ruleId === 'images.primary')?.outcome, 'pass');
- });
- test('ambiguous image groups produce a numeric primary failure instead of a dynamic denominator', () => {
- const normalized = normalizeJdListing({
- workspaceId: 'demashi', shopId: 'shop', row: { productId: 'ambiguous-groups' },
- detail: { productInfo: { productId: 'ambiguous-groups' }, material: { mainImages: [
- { uuid: 'sku-1', imageInfoList: [{ imgUrl: 'sku-1.jpg', orderSort: 1, primaryFlag: true }] },
- { uuid: 'sku-2', imageInfoList: [{ imgUrl: 'sku-2.jpg', orderSort: 1, primaryFlag: true }] },
- ] } },
- });
- const result = scoreListing(normalized);
- assert.equal(normalized.images.length, 0);
- assert.equal(result.dimensions.find((item) => item.dimension === 'images')?.evidence.find((item) => item.ruleId === 'images.primary')?.outcome, 'fail');
- assert.deepEqual(result.unknownCriteria, []);
- });
- test('a result without an overall score is classified as partial instead of disappearing between filters', () => {
- const input = source();
- const result = scoreListing(input);
- const partial = { ...result, overallScore: null };
- assert.equal(listingProductScoreStatus(input, partial), 'partial');
- });
- test('the presentation gate never falls back to a legacy score version', () => {
- const input=source();
- const current=scoreListing(input);
- const legacy={...current,rubricVersion:'listing-jd-v4',overallScore:88,knownOverallScore:88,knownOverallMaxScore:100};
- assert.equal(selectCurrentListingScore([legacy],input.sourceHash).displayScore,null);
- assert.equal(selectCurrentListingScore([current],input.sourceHash).displayScore?.rubricVersion,'listing-jd-v7');
- });
- test('V7 migration creates current slots and physically drops score history', async () => {
- const migration = await readFile(new URL('../migrations/012_listing_remove_score_history.sql', import.meta.url), 'utf8');
- assert.match(migration, /CREATE TABLE IF NOT EXISTS voc\.listing_current_score/);
- assert.match(migration, /UNIQUE \(workspace_id, product_id, slot\)/);
- assert.match(migration, /DROP TABLE voc\.listing_score_result/);
- assert.match(migration, /DROP COLUMN IF EXISTS score_result_public_id/);
- assert.match(migration, /DROP COLUMN IF EXISTS base_score_result_public_id/);
- });
- test('listing context joins SKU categories and versioned category VOC evidence', () => {
- const index = buildListingContextIndex(
- [{ productId: 'sku-1', category3: '商用冰箱', title: 'SKU 商品' }],
- [{ competitorProductId: 'competitor-1', category: '商用冰箱' }],
- [{ productId: 'competitor-1', reviewKey: 'review-1', rating: 2, content: '容易结霜,容量偏小', reviewDate: '2026-08-20T00:00:00.000Z' }],
- );
- const enriched = index.enrich(source(), '2026-08-24T00:00:00.000Z');
- assert.equal(enriched.contextVersion, LISTING_CONTEXT_VERSION);
- assert.deepEqual(enriched.categoryContext?.coreTerms, ['商用冰箱']);
- assert.equal(enriched.categoryContext?.ruleVersion, 'jd-category-rules-2026-08-v3');
- assert.equal(enriched.vocEvidence?.[0]?.text, '容易结霜,容量偏小');
- assert.notEqual(enriched.sourceHash, source().sourceHash);
- });
|