listing-ai.overview-query.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import assert from 'node:assert/strict';
  2. import test from 'node:test';
  3. import type { ListingDimension, ListingOverviewQuery, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  4. import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js';
  5. import { LISTING_SIMULATION_MODEL, queryListingOverview } from '../src/modules/listing-ai/query/listing-overview.query.js';
  6. import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
  7. import { listingOverviewQuerySchema, listingOverviewResponseSchema } from '../src/modules/listing-ai/schemas.js';
  8. import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js';
  9. import { LISTING_DIMENSION_MAX, LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
  10. import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
  11. const WORKSPACE_ID = 'overview-test';
  12. const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
  13. function source(productId: string, categoryId: string, syncedAt = '2026-08-26T00:00:00.000Z'): ListingSourceSnapshot {
  14. return {
  15. id: `source-${productId}`, workspaceId: WORKSPACE_ID, platform: 'jd', shopId: 'shop', productId,
  16. sourceHash: productId.padStart(64, '0'), title: `测试商品 ${productId}`, titleBrandName: '测试品牌', brand: { id: 'brand', name: '测试品牌' },
  17. categoryIds: ['root', categoryId], categoryContext: { names: ['商用设备', `类目 ${categoryId}`], categoryId, pathNames: ['商用设备', `类目 ${categoryId}`], displayName: `类目 ${categoryId}`, coreTerms: [], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'test' },
  18. itemStatus: '1', price: { jd: 100, cost: null }, descriptions: { desktopHtml: '<p>详情</p>', mobileHtml: null }, features: [], attributes: [],
  19. images: [{ url: `https://img.test/${productId}.jpg`, order: 1, isPrimary: true, gptFlag: null }], skus: [],
  20. dimensions: { length: null, width: null, height: null, weight: null }, logistics: {}, afterService: {}, sourceModifiedAt: null, syncedAt, detailStatus: 'available',
  21. };
  22. }
  23. function score(input: {
  24. source: ListingSourceSnapshot;
  25. overall: number;
  26. values: Record<ListingDimension, number>;
  27. nature: 'simulation' | 'formal_ai' | 'rule_precheck';
  28. }): ListingScoreResult {
  29. const hybrid = input.nature !== 'rule_precheck';
  30. return {
  31. id: `score-${input.source.productId}-${input.nature}`, workspaceId: WORKSPACE_ID, productId: input.source.productId, sourceHash: input.source.sourceHash,
  32. rubricVersion: hybrid ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION, overallScore: input.overall,
  33. coverage: { percent: 100, missing: [], status: 'eligible' },
  34. dimensions: DIMENSIONS.map((dimension) => ({ dimension, score: input.values[dimension], maxScore: LISTING_DIMENSION_MAX[dimension], coverage: 100, status: 'scored', evidence: [], suggestions: [] })),
  35. aiStatus: hybrid ? 'completed' : 'not_requested', aiSuggestions: [], aiCandidate: null,
  36. model: input.nature === 'simulation' ? LISTING_SIMULATION_MODEL : input.nature === 'formal_ai' ? 'production-model' : null,
  37. promptVersion: hybrid ? 'test-prompt' : null, scoreKind: hybrid ? 'hybrid_ai' : 'rules', createdAt: `2026-08-26T0${input.source.productId}:00:00.000Z`,
  38. };
  39. }
  40. const sources = [source('1', 'cat-a'), source('2', 'cat-a'), source('3', 'cat-b'), source('4', 'cat-c')];
  41. const scores = [
  42. score({ source: sources[0]!, overall: 74, nature: 'simulation', values: { title: 24, selling_points: 20, images: 10, description: 12, specifications: 8 } }),
  43. score({ source: sources[1]!, overall: 90, nature: 'formal_ai', values: { title: 27, selling_points: 22.5, images: 18, description: 13.5, specifications: 9 } }),
  44. score({ source: sources[2]!, overall: 60, nature: 'rule_precheck', values: { title: 18, selling_points: 15, images: 12, description: 9, specifications: 6 } }),
  45. ];
  46. function query(patch: Partial<ListingOverviewQuery> = {}): ListingOverviewQuery {
  47. return { workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'improvementPotential', direction: 'desc', limit: 25, cursor: null, ...patch };
  48. }
  49. test('overview rows calculate normalized dimensions, weakest dimension, improvement potential, and score nature', () => {
  50. const result = queryListingOverview({ sources, scores, query: query(), generatedAt: '2026-08-26T12:00:00.000Z' });
  51. const simulation = result.items.find((item) => item.productId === '1')!;
  52. assert.equal(simulation.scoreNature, 'simulation');
  53. assert.equal(simulation.scoreNatureLabel, '模拟评分');
  54. assert.equal(simulation.overallRate, 0.74);
  55. assert.deepEqual(simulation.dimensions.images, { score: 10, maxScore: 20, rate: 0.5, gap: 10 });
  56. assert.equal(simulation.weakestDimension, 'images', 'weakest dimension uses rate instead of raw points');
  57. assert.equal(simulation.improvementPotential, 26);
  58. assert.equal(simulation.categoryId, 'cat-a');
  59. assert.deepEqual(simulation.categoryPath, ['商用设备', '类目 cat-a']);
  60. const unscored = result.items.find((item) => item.productId === '4')!;
  61. assert.equal(unscored.scoreNature, 'unscored');
  62. assert.equal(unscored.overallScore, null);
  63. assert.equal(unscored.dimensions.title.score, null);
  64. assert.equal(unscored.weakestDimension, null);
  65. assert.equal(unscored.improvementPotential, null, 'missing dimensions are not treated as zero points');
  66. const unscoredOnly = queryListingOverview({ sources, scores, query: query({ scoreNature: 'unscored' }), generatedAt: '2026-08-26T12:00:00.000Z' });
  67. assert.equal(unscoredOnly.summary.dimensionStats.title.totalGap, null);
  68. });
  69. test('overview aggregates the complete filtered set before pagination and returns distribution, health, and facets', () => {
  70. const result = queryListingOverview({ sources, scores, query: query({ limit: 2 }), generatedAt: '2026-08-26T12:00:00.000Z' });
  71. assert.equal(result.items.length, 2);
  72. assert.ok(result.nextCursor);
  73. assert.equal(result.summary.sourceTotal, 4);
  74. assert.equal(result.summary.matchedTotal, 4);
  75. assert.equal(result.summary.scoredTotal, 3);
  76. assert.equal(result.summary.simulationTotal, 1);
  77. assert.equal(result.summary.averageScore, 74.67);
  78. assert.equal(result.summary.medianScore, 74);
  79. assert.deepEqual(result.summary.scoreDistribution.map((bucket) => bucket.count), [0, 1, 1, 0, 1]);
  80. assert.deepEqual(result.summary.dimensionStats.title, {
  81. key: 'title', label: '商品标题', maxScore: 30, scoredCount: 3, averageScore: 23, averageRate: 0.7667, medianScore: 24, totalGap: 21,
  82. });
  83. assert.deepEqual(result.summary.categoryFacets.map((facet) => [facet.categoryId, facet.count]), [['cat-a', 2], ['cat-b', 1], ['cat-c', 1]]);
  84. assert.deepEqual(result.summary.scoreNatureFacets.map((facet) => [facet.value, facet.count]), [['simulation', 1], ['formal_ai', 1], ['rule_precheck', 1], ['unscored', 1]]);
  85. assert.equal(result.summary.generatedAt, '2026-08-26T12:00:00.000Z');
  86. assert.ok(result.summary.snapshotId);
  87. listingOverviewResponseSchema.parse(result);
  88. });
  89. test('overview applies category, nature, total, dimension, and weakest-dimension filters globally', () => {
  90. const result = queryListingOverview({
  91. sources,
  92. scores,
  93. query: query({ categoryIds: ['cat-a', 'missing'], scoreNature: 'simulation', minScore: 70, maxScore: 80, imagesMin: 9, imagesMax: 11, weakestDimension: 'images' }),
  94. generatedAt: '2026-08-26T12:00:00.000Z',
  95. });
  96. assert.equal(result.summary.sourceTotal, 4);
  97. assert.equal(result.summary.matchedTotal, 1);
  98. assert.deepEqual(result.items.map((item) => item.productId), ['1']);
  99. assert.equal(result.summary.averageScore, 74);
  100. assert.equal(result.summary.dimensionStats.images.totalGap, 10);
  101. });
  102. test('overview reuses stable cursors and rejects a changed snapshot', () => {
  103. const first = queryListingOverview({ sources, scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1 }), generatedAt: '2026-08-26T12:00:00.000Z' });
  104. assert.deepEqual(first.items.map((item) => item.productId), ['2']);
  105. const second = queryListingOverview({ sources, scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1, cursor: first.nextCursor }), generatedAt: '2026-08-26T12:00:01.000Z' });
  106. assert.deepEqual(second.items.map((item) => item.productId), ['1']);
  107. assert.throws(
  108. () => queryListingOverview({ sources: [{ ...sources[0]!, syncedAt: '2026-08-26T13:00:00.000Z' }, ...sources.slice(1)], scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1, cursor: first.nextCursor }), generatedAt: '2026-08-26T12:00:01.000Z' }),
  109. (error: unknown) => error instanceof Error && 'code' in error && error.code === 'listing_overview_cursor_stale',
  110. );
  111. });
  112. test('overview query schema normalizes category IDs, applies defaults, and rejects inverted ranges', () => {
  113. const parsed = listingOverviewQuerySchema.parse({ categoryIds: ['cat-a,cat-b', 'cat-a'] });
  114. assert.deepEqual(parsed.categoryIds, ['cat-a', 'cat-b']);
  115. assert.equal(parsed.sort, 'improvementPotential');
  116. assert.equal(parsed.direction, 'desc');
  117. assert.equal(parsed.limit, 25);
  118. assert.throws(() => listingOverviewQuerySchema.parse({ minScore: 90, maxScore: 80 }));
  119. assert.throws(() => listingOverviewQuerySchema.parse({ titleMax: 31 }));
  120. });
  121. test('overview isolates JD-VOC scores by rubricVersion and scoreKind', () => {
  122. const jdVoc = scoreJdVocRules(sources[1]!, {}, { id: 'jd-voc-overview', now: '2026-08-26T12:00:00.000Z' });
  123. const result = queryListingOverview({ sources, scores, jdVocScores: [jdVoc], query: query({ rubricVersion: 'jd-voc-v0.5', scoreKind: 'jd_voc_rules' }), generatedAt: '2026-08-26T12:00:00.000Z' });
  124. assert.deepEqual(result.items.map((item) => item.productId), ['2']);
  125. assert.equal(result.items[0]?.rubricVersion, 'jd-voc-v0.5');
  126. assert.equal(result.items[0]?.scoreKind, 'jd_voc_rules');
  127. assert.equal(result.items[0]?.jdVocDimensions?.search.maxScore, 25);
  128. assert.ok(result.items[0]?.weakestDimension);
  129. assert.equal(result.summary.jdVocDimensionStats?.media.maxScore, 5);
  130. });
  131. test('overview service performs one source read and one current-score read without per-product queries', async () => {
  132. class CountingRepository extends InMemoryListingAiRepository {
  133. sourceReads = 0;
  134. scoreReads = 0;
  135. override async listAllSources(workspaceId: string, platform: 'jd') {
  136. this.sourceReads += 1;
  137. return super.listAllSources(workspaceId, platform);
  138. }
  139. override async listCurrentScores(workspaceId: string) {
  140. this.scoreReads += 1;
  141. return super.listCurrentScores(workspaceId);
  142. }
  143. }
  144. const repository = new CountingRepository(sources);
  145. for (const currentScore of scores) await repository.upsertCurrentScore(currentScore);
  146. const service = new ListingAiService(repository, undefined, () => new Date('2026-08-26T12:00:00.000Z'));
  147. const result = await service.overview(query());
  148. assert.equal(result.summary.sourceTotal, 4);
  149. assert.equal(repository.sourceReads, 1);
  150. assert.equal(repository.scoreReads, 1);
  151. });