jd-voc-ai-rubric.test.ts 7.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  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 { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
  5. import { composeJdVocHybridScore, jdVocAiEvidenceCatalog, jdVocAiPrompt, parseJdVocAiScoreOutput, validateJdVocAiOutput } from '../src/modules/listing-ai/scoring/jd-voc-ai-rubric.js';
  6. import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
  7. import { ListingAiService, type JdVocAiScoringProvider } from '../src/modules/listing-ai/listing-ai.service.js';
  8. const source = {
  9. id: 'source-ai', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'ai-jd-1', sourceHash: 'b'.repeat(64),
  10. title: '德玛仕 商用削皮机 500W', titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' }, categoryIds: ['food'],
  11. categoryContext: { names: ['削皮机'], coreTerms: ['削皮机'], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'peeler-v1' }, itemStatus: 'on_shelf',
  12. price: { jd: 1000, cost: null }, descriptions: { desktopHtml: '<p>商用</p>', mobileHtml: '<p>商用</p>' }, descriptionStructure: { observed: true, imageCount: 1, videoCount: 0, headingCount: 0, faqCandidateCount: 0 },
  13. features: [{ key: 'power', value: '500W' }], attributes: [{ id: '1', name: '功率', values: ['500W'] }], images: [{ url: 'https://img.test/1.jpg', order: 1, isPrimary: true, gptFlag: false }], imageAssets: { defaultImages: [], skuImages: [], whiteBackgroundImages: [] }, skus: [], dimensions: { length: 1, width: 1, height: 1, weight: 1 }, logistics: {}, afterService: {}, marketing: { adword: '500W', skuShortTitles: [], sellingPoints: [{ value: '500W', source: 'product_adword', fieldPath: 'adword', skuId: null }] }, sourceModifiedAt: null, syncedAt: '2026-09-04T08:00:00.000Z', detailStatus: 'available',
  14. } as ListingSourceSnapshot;
  15. const baseline = scoreJdVocRules(source, { productReviews: [{ id: 'review-1', source: 'product-review', text: '削皮速度稳定' }] }, { id: 'baseline', now: '2026-09-04T08:00:00.000Z' });
  16. const validOutput = () => ({
  17. assessments: (['search', 'voc', 'selling', 'facts', 'competitive'] as const).map((dimension) => ({ dimension, score: ({ search: 20, voc: 20, selling: 12, facts: 15, competitive: 5 } as const)[dimension], evidenceIds: ['source.title'], rationale: '基于字段证据', confidence: 0.8 })),
  18. suggestions: [{ title: '补充功率说明', action: '在卖点中说明已观测功率', evidenceIds: ['source.title'] }], summary: '完成语义复核',
  19. });
  20. test('JD-VOC AI parser requires five unique semantic dimensions and bounded scores', () => {
  21. const parsed = parseJdVocAiScoreOutput(JSON.stringify(validOutput()));
  22. assert.equal(parsed?.assessments.length, 5);
  23. assert.match(jdVocAiPrompt(), /search, voc, selling, facts, competitive/);
  24. assert.match(jdVocAiPrompt(), /media 图片维度由规则独占/);
  25. const duplicate = { ...validOutput(), assessments: validOutput().assessments.map((item, index) => index === 4 ? { ...item, dimension: 'search' as const } : item) };
  26. assert.equal(parseJdVocAiScoreOutput(JSON.stringify(duplicate)), null);
  27. });
  28. test('JD-VOC AI validation rejects unknown evidence and filters fabricated numeric suggestions', () => {
  29. const output = validOutput();
  30. const catalog = jdVocAiEvidenceCatalog(source, baseline);
  31. assert.throws(() => validateJdVocAiOutput({ ...output, assessments: output.assessments.map((item) => ({ ...item, evidenceIds: ['does-not-exist'] })) }, baseline, catalog, source), /evidence_invalid/);
  32. const filtered = validateJdVocAiOutput({ ...output, suggestions: [{ title: '补充 9999W', action: '宣称 9999W', evidenceIds: ['source.title'] }] }, baseline, catalog, source);
  33. assert.equal(filtered.suggestions.length, 0);
  34. });
  35. test('JD-VOC hybrid composition uses 65/35 and preserves blocked rule results', () => {
  36. const result = composeJdVocHybridScore({ baseline, output: validOutput(), source, model: 'gpt-4o-mini', now: '2026-09-04T08:01:00.000Z' });
  37. const search = result.dimensions.find((item) => item.key === 'search');
  38. const ruleSearch = baseline.dimensions.find((item) => item.key === 'search')?.score;
  39. assert.equal(search?.score, ruleSearch === null || ruleSearch === undefined ? null : Math.round((ruleSearch * 0.65 + 20 * 0.35) * 10) / 10);
  40. assert.equal(result.scoreKind, 'jd_voc_hybrid_ai');
  41. assert.equal(result.dimensions.find((item) => item.key === 'media')?.score, baseline.dimensions.find((item) => item.key === 'media')?.score);
  42. const blocked = scoreJdVocRules({ ...source, detailStatus: 'empty', title: null, features: [], attributes: [], images: [] });
  43. const blockedSource = { ...source, detailStatus: 'empty' as const, title: null, features: [], attributes: [], images: [] };
  44. const blockedOutput = { ...validOutput(), assessments: validOutput().assessments.map((item) => ({ ...item, evidenceIds: ['source.brand'] })) };
  45. assert.equal(composeJdVocHybridScore({ baseline: blocked, output: blockedOutput, source: blockedSource, model: 'gpt-4o-mini', now: '2026-09-04T08:01:00.000Z' }).overallScore, null);
  46. });
  47. test('JD-VOC score jobs persist AI slots, reuse cache, and keep rules on provider failure', async () => {
  48. class Provider implements JdVocAiScoringProvider {
  49. configured = true;
  50. model = 'gpt-4o-mini';
  51. calls = 0;
  52. fail = false;
  53. async score() { this.calls += 1; if (this.fail) throw new Error('test_ai_failure'); return validOutput(); }
  54. }
  55. class TrackingRepository extends InMemoryListingAiRepository {
  56. statuses:string[]=[];
  57. override async updateJobItem(item:Parameters<InMemoryListingAiRepository['updateJobItem']>[0]){this.statuses.push(item.status);return super.updateJobItem(item);}
  58. }
  59. const repository = new TrackingRepository([source]);
  60. const provider = new Provider();
  61. const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, provider);
  62. const run = async (key: string, policy: 'reuse' | 'force' = 'reuse') => {
  63. const job = await service.enqueueScoreJob({ workspaceId: source.workspaceId, platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, rubricVersion: 'jd-voc-v0.5', includeAiSuggestions: true, rescorePolicy: policy, idempotencyKey: key, requestedBy: 'test' });
  64. for (let index = 0; index < 100; index += 1) { const current = await repository.getJob(source.workspaceId, job.id); if (current && ['completed', 'partial', 'failed'].includes(current.status)) return current; await new Promise((resolve) => setTimeout(resolve, 2)); }
  65. assert.fail('JD-VOC job did not finish');
  66. };
  67. await run('jd-voc-ai-job-1');
  68. assert.deepEqual(repository.statuses.slice(0,3), ['rules_scored','ai_pending','partial']);
  69. assert.equal((await repository.getJdVocCurrentScore(source.workspaceId, source.productId, 'jd_voc_hybrid_ai'))?.aiReview?.status, 'completed');
  70. assert.equal(provider.calls, 1);
  71. await run('jd-voc-ai-job-2');
  72. assert.equal(provider.calls, 1, 'same source/model/prompt/context reuses the hybrid slot');
  73. provider.fail = true;
  74. const failed = await run('jd-voc-ai-job-3', 'force');
  75. assert.equal(failed.status, 'failed');
  76. assert.equal((await repository.getJdVocCurrentScore(source.workspaceId, source.productId, 'jd_voc_rules'))?.scoreKind, 'jd_voc_rules');
  77. });