jd-voc-rule-engine.test.ts 4.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
  1. import assert from 'node:assert/strict';
  2. import test from 'node:test';
  3. import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  4. import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
  5. import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
  6. import { ApiError } from '../src/http/api-error.js';
  7. import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
  8. function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSnapshot {
  9. return {
  10. id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '10020722928820', sourceHash: 'a'.repeat(64),
  11. title: '德玛仕 商用削皮机 304不锈钢 自动削皮', titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' }, categoryIds: ['food'],
  12. categoryContext: { names: ['削皮机'], coreTerms: ['削皮机'], requiredSpecificationNames: ['功率'], qualificationNames: [], ruleVersion: 'peeler-v1' }, itemStatus: 'on_shelf',
  13. price: { jd: 1000, cost: 800 }, descriptions: { desktopHtml: '<p>适用于食堂</p>', mobileHtml: '<p>适用于食堂</p>' },
  14. descriptionStructure: { observed: true, imageCount: 1, videoCount: 0, headingCount: 0, faqCandidateCount: 0 },
  15. features: [{ key: 'power', value: '500W' }], attributes: [{ id: '1', name: '功率', values: ['500W'] }],
  16. images: [{ url: 'https://img.test/1.jpg', order: 1, isPrimary: true, gptFlag: false }], imageAssets: { defaultImages: [], skuImages: [], whiteBackgroundImages: [] },
  17. skus: [], dimensions: { length: 10, width: 10, height: 10, weight: 5 }, logistics: {}, afterService: {},
  18. marketing: { adword: '自动削皮', skuShortTitles: [], sellingPoints: [{ value: '自动削皮', source: 'product_adword', fieldPath: 'adword', skuId: null }] },
  19. sourceModifiedAt: null, syncedAt: '2026-09-04T08:00:00.000Z', detailStatus: 'available', ...overrides,
  20. };
  21. }
  22. test('jd-voc rule engine emits six dimensions, stable execution identity, and separated VOC sources', () => {
  23. const input = source();
  24. const first = scoreJdVocRules(input, { productReviews: [{ id: 'r1', source: 'product-review', text: '削皮速度可以' }] }, { id: 'fixed', now: '2026-09-04T08:00:00.000Z' });
  25. const second = scoreJdVocRules(input, { productReviews: [{ id: 'r1', source: 'product-review', text: '削皮速度可以' }] }, { id: 'fixed', now: '2026-09-04T08:00:00.000Z' });
  26. assert.deepEqual(first, second);
  27. assert.equal(first.rubricVersion, 'jd-voc-v0.5');
  28. assert.equal(first.scoreKind, 'jd_voc_rules');
  29. assert.deepEqual(first.dimensions.map((item) => item.key), ['search', 'voc', 'selling', 'facts', 'competitive', 'media']);
  30. assert.equal(first.voc.productReviewCount, 1);
  31. assert.equal(first.voc.categoryVocEvidenceCount, 0);
  32. assert.equal(first.executionKey, 'jd-voc-v0.5:' + first.inputFingerprint);
  33. });
  34. test('missing VOC and competitors remain unknown/partial instead of zero', () => {
  35. const result = scoreJdVocRules(source({ detailStatus: 'empty', title: null, features: [], attributes: [], images: [] }));
  36. assert.equal(result.dimensions.find((item) => item.key === 'voc')?.score, null);
  37. assert.equal(result.dimensions.find((item) => item.key === 'competitive')?.score, null);
  38. assert.equal(result.coverage.status, 'blocked');
  39. });
  40. test('service persists JD-VOC result idempotently in its dedicated slot', async () => {
  41. const repository = new InMemoryListingAiRepository([source()]);
  42. const service = new ListingAiService(repository, undefined, () => new Date('2026-09-04T08:00:00.000Z'));
  43. const first = await service.scoreJdVocRules({ workspaceId: 'demashi', platform: 'jd', productId: '10020722928820' });
  44. const second = await service.scoreJdVocRules({ workspaceId: 'demashi', platform: 'jd', productId: '10020722928820' });
  45. assert.deepEqual(second, first);
  46. assert.equal((await repository.getJdVocCurrentScore('demashi', '10020722928820', 'jd_voc_rules'))?.executionKey, first.executionKey);
  47. assert.equal((await repository.getCurrentScore('demashi', '10020722928820')), null);
  48. });
  49. test('JD_VOC_ENABLED=false blocks new score writes while persisted reads remain available', async () => {
  50. const repository = new InMemoryListingAiRepository([source()]);
  51. const existing = scoreJdVocRules(source(), {}, { id:'existing', now:'2026-09-05T00:00:00.000Z' });
  52. await repository.upsertJdVocCurrentScore(existing);
  53. const service = new ListingAiService(repository, undefined, () => new Date(), 1, 10, undefined, undefined, false, false);
  54. assert.equal((await repository.getJdVocCurrentScore('demashi', '10020722928820'))?.id, 'existing');
  55. await assert.rejects(service.scoreJdVocRules({ workspaceId:'demashi', platform:'jd', productId:'10020722928820' }), (error:unknown)=>error instanceof ApiError&&error.code==='jd_voc_disabled');
  56. });