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

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