listing-ai.ai-rubric.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  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 { ListingAiService, type ListingAiScoringProvider } from '../src/modules/listing-ai/listing-ai.service.js';
  5. import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
  6. import { composeListingAiScore, LISTING_AI_CRITERIA, LISTING_AI_PROMPT_VERSION, LISTING_AI_RUBRIC_VERSION, parseListingAiScoreOutput, type ListingAiScoreOutput } from '../src/modules/listing-ai/scoring/ai-rubric.js';
  7. import { scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
  8. const source: ListingSourceSnapshot = {
  9. id: 'source-ai-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'ai-1001', sourceHash: 'e'.repeat(64),
  10. title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜便利店商超适用', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10'],
  11. categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量', '制冷方式'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: '1',
  12. price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'299L大容量,一级能效,风冷无霜,适合便利店使用。'.repeat(20)}</p>`, mobileHtml: `<p>${'299L大容量,一级能效,风冷无霜。'.repeat(20)}</p>` },
  13. descriptionStructure: { observed: true, imageCount: 8, videoCount: 1, headingCount: 4, faqCandidateCount: 5 },
  14. features: [{ key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' }, { key: 'model', value: 'BC-299' }],
  15. attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效等级', values: ['一级'] }],
  16. images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: null })),
  17. skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 10, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
  18. dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: {}, afterService: { return7Days: true },
  19. 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' }] },
  20. vocEvidence: [{ id: 'voc-1', text: '容量不足和结霜是主要顾虑', sourceVersion: 'voc-v1', collectedAt: '2026-08-20T00:00:00.000Z' }],
  21. sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
  22. };
  23. class StableAiJudge implements ListingAiScoringProvider {
  24. configured = true;
  25. model = 'deepseek-v4-pro';
  26. calls = 0;
  27. async score(): Promise<ListingAiScoreOutput> {
  28. this.calls += 1;
  29. return {
  30. assessments: LISTING_AI_CRITERIA.map((criterion) => ({
  31. criterionId: criterion.id, level: 'strong' as const, evidenceIds: ['title'], reason: '证据充分', confidence: 0.9,
  32. })),
  33. summary: '结构和语义均完整', suggestions: ['保持标题、卖点与规格一致'],
  34. };
  35. }
  36. }
  37. async function waitForTerminal(service: ListingAiService, jobId: string): Promise<void> {
  38. for (let index = 0; index < 100; index += 1) {
  39. const job = await service.repository.getJob('demashi', jobId);
  40. if (job && ['completed', 'partial', 'failed'].includes(job.status)) return;
  41. await new Promise((resolve) => setTimeout(resolve, 5));
  42. }
  43. assert.fail('AI score job did not reach terminal state');
  44. }
  45. test('AI rubric uses fixed criteria, server-side composition, and stable cache identity', async () => {
  46. const repository = new InMemoryListingAiRepository([source]);
  47. const judge = new StableAiJudge();
  48. const service = new ListingAiService(repository, judge, () => new Date('2026-08-21T10:00:00.000Z'), 1, 10);
  49. const first = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-first', requestedBy: 'test' });
  50. await waitForTerminal(service, first.id);
  51. const result = await repository.getCurrentScore('demashi', source.productId, 'formal_ai');
  52. assert.equal(result?.overallScore, 100);
  53. assert.equal(result?.scoreKind, 'hybrid_ai');
  54. assert.equal(result?.promptVersion, LISTING_AI_PROMPT_VERSION);
  55. assert.equal(result?.aiCandidate, null);
  56. assert.equal(result?.dimensions.find((item) => item.dimension === 'images')?.evidence.some((item) => item.ruleId.startsWith('ai.')), false);
  57. assert.equal(judge.calls, 1);
  58. const second = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-second', requestedBy: 'test' });
  59. await waitForTerminal(service, second.id);
  60. assert.equal(judge.calls, 1, 'same source/rubric/model/prompt must reuse the stored AI score');
  61. const beforeForce = await repository.listCurrentScores('demashi');
  62. const forced = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, rescorePolicy: 'force', idempotencyKey: 'ai-score-forced', requestedBy: 'test' });
  63. await waitForTerminal(service, forced.id);
  64. assert.equal(judge.calls, 2, 'force must invoke the model even when the input fingerprint is unchanged');
  65. const afterForce = await repository.listCurrentScores('demashi');
  66. assert.equal(afterForce.length, beforeForce.length, 'force must overwrite the two current slots instead of appending history');
  67. assert.equal(afterForce.filter((item) => item.scoreKind === 'hybrid_ai').length, 1);
  68. });
  69. test('AI output parser normalizes a scalar evidence ID without relaxing rubric completeness', () => {
  70. const content = JSON.stringify({
  71. assessments: LISTING_AI_CRITERIA.map((criterion) => ({ criterionId: criterion.id, level: 'pass', evidenceIds: 'title', reason: '有直接证据', confidence: 0.8 })),
  72. summary: '完成', suggestions: [],
  73. });
  74. const parsed = parseListingAiScoreOutput(content);
  75. assert.deepEqual(parsed?.assessments[0]?.evidenceIds, ['title']);
  76. });
  77. test('image-only descriptions are scored from observable asset structure without semantic AI criteria', async () => {
  78. const imageOnly = { ...source, descriptions: { desktopHtml: '<p><img src="https://img.test/detail.jpg"></p>', mobileHtml: '<img src="https://img.test/detail-mobile.jpg">' } };
  79. const output = await new StableAiJudge().score();
  80. const result = composeListingAiScore({ source: imageOnly, baseline: scoreListing(imageOnly), output, model: 'test-model', now: '2026-08-24T00:00:00.000Z' });
  81. const description = result.dimensions.find((item) => item.dimension === 'description');
  82. assert.equal(description?.evidence.some((item) => item.ruleId.startsWith('ai.')), false);
  83. assert.equal(description?.score, 15);
  84. assert.equal(result.knownOverallMaxScore, 100);
  85. });
  86. test('an observable empty product adword receives a numeric score instead of unknown', async () => {
  87. const skuOnly = { ...source, marketing: { ...source.marketing!, adword: null, sellingPoints: source.marketing!.sellingPoints.filter((item) => item.source === 'sku_short_title') } };
  88. const output = await new StableAiJudge().score();
  89. const result = composeListingAiScore({ source: skuOnly, baseline: scoreListing(skuOnly), output, model: 'test-model', now: '2026-08-24T00:00:00.000Z' });
  90. const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
  91. assert.equal(sellingPoints?.evidence.some((item) => item.outcome === 'unknown'), false);
  92. assert.equal(typeof sellingPoints?.score, 'number');
  93. });
  94. test('AI failures remain on the job and do not overwrite the current formal score', async () => {
  95. const repository = new InMemoryListingAiRepository([source]);
  96. const provider: ListingAiScoringProvider = { configured: true, model: 'failing-model', score: async () => { throw new Error('ai_upstream_test'); } };
  97. const service = new ListingAiService(repository, provider, () => new Date('2026-08-24T00:00:00.000Z'), 1, 10);
  98. const rulesJob = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: false, idempotencyKey: 'rules-before-ai-failure', requestedBy: 'test' });
  99. await waitForTerminal(service, rulesJob.id);
  100. const job = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, rescorePolicy: 'force', idempotencyKey: 'ai-score-failure', requestedBy: 'test' });
  101. await waitForTerminal(service, job.id);
  102. assert.equal(await repository.getCurrentScore('demashi', source.productId, 'formal_ai'), null);
  103. assert.equal((await repository.getCurrentScore('demashi', source.productId, 'rule_precheck'))?.scoreKind, 'rules');
  104. assert.equal((await repository.getJobItems('demashi', job.id))[0]?.status, 'failed');
  105. });
  106. test('VOC and unverified category requirements do not create partial V7 results', async () => {
  107. const partialSource = { ...source, productId: 'ai-partial', sourceHash: 'f'.repeat(64), vocEvidence: [], categoryContext: { ...source.categoryContext!, requiredSpecificationNames: [], ruleVersion: null } };
  108. const repository = new InMemoryListingAiRepository([partialSource]);
  109. const judge = new StableAiJudge();
  110. const service = new ListingAiService(repository, judge, () => new Date('2026-08-24T01:00:00.000Z'), 1, 10);
  111. const job = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [partialSource.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-partial-evidence', requestedBy: 'test' });
  112. await waitForTerminal(service, job.id);
  113. const result = await repository.getCurrentScore('demashi', partialSource.productId, 'formal_ai');
  114. assert.equal(judge.calls, 1);
  115. assert.equal(typeof result?.overallScore, 'number');
  116. assert.equal(result?.dimensions.flatMap((item) => item.evidence).some((item) => item.ruleId.includes('voc') || item.ruleId.includes('category_completeness')), false);
  117. assert.equal((await repository.getJob('demashi', job.id))?.status, 'completed');
  118. const item = (await repository.getJobItems('demashi', job.id))[0];
  119. assert.equal(item?.errorCode, null);
  120. assert.deepEqual(item?.statusReasonCodes, []);
  121. });