| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132 |
- import assert from 'node:assert/strict';
- import test from 'node:test';
- import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
- import { ListingAiService, type ListingAiScoringProvider } from '../src/modules/listing-ai/listing-ai.service.js';
- import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
- 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';
- import { scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
- const source: ListingSourceSnapshot = {
- id: 'source-ai-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'ai-1001', sourceHash: 'e'.repeat(64),
- title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜便利店商超适用', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10'],
- categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量', '制冷方式'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: '1',
- price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'299L大容量,一级能效,风冷无霜,适合便利店使用。'.repeat(20)}</p>`, mobileHtml: `<p>${'299L大容量,一级能效,风冷无霜。'.repeat(20)}</p>` },
- descriptionStructure: { observed: true, imageCount: 8, videoCount: 1, headingCount: 4, faqCandidateCount: 5 },
- features: [{ key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' }, { key: 'model', value: 'BC-299' }],
- attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效等级', values: ['一级'] }],
- images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: null })),
- skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 10, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
- dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: {}, afterService: { return7Days: true },
- 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' }] },
- vocEvidence: [{ id: 'voc-1', text: '容量不足和结霜是主要顾虑', sourceVersion: 'voc-v1', collectedAt: '2026-08-20T00:00:00.000Z' }],
- sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available',
- };
- class StableAiJudge implements ListingAiScoringProvider {
- configured = true;
- model = 'deepseek-v4-pro';
- calls = 0;
- async score(): Promise<ListingAiScoreOutput> {
- this.calls += 1;
- return {
- assessments: LISTING_AI_CRITERIA.map((criterion) => ({
- criterionId: criterion.id, level: 'strong' as const, evidenceIds: ['title'], reason: '证据充分', confidence: 0.9,
- })),
- summary: '结构和语义均完整', suggestions: ['保持标题、卖点与规格一致'],
- };
- }
- }
- async function waitForTerminal(service: ListingAiService, jobId: string): Promise<void> {
- for (let index = 0; index < 100; index += 1) {
- const job = await service.repository.getJob('demashi', jobId);
- if (job && ['completed', 'partial', 'failed'].includes(job.status)) return;
- await new Promise((resolve) => setTimeout(resolve, 5));
- }
- assert.fail('AI score job did not reach terminal state');
- }
- test('AI rubric uses fixed criteria, server-side composition, and stable cache identity', async () => {
- const repository = new InMemoryListingAiRepository([source]);
- const judge = new StableAiJudge();
- const service = new ListingAiService(repository, judge, () => new Date('2026-08-21T10:00:00.000Z'), 1, 10);
- const first = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-first', requestedBy: 'test' });
- await waitForTerminal(service, first.id);
- const result = await repository.getCurrentScore('demashi', source.productId, 'formal_ai');
- assert.equal(result?.overallScore, 100);
- assert.equal(result?.scoreKind, 'hybrid_ai');
- assert.equal(result?.promptVersion, LISTING_AI_PROMPT_VERSION);
- assert.equal(result?.aiCandidate, null);
- assert.equal(result?.dimensions.find((item) => item.dimension === 'images')?.evidence.some((item) => item.ruleId.startsWith('ai.')), false);
- assert.equal(judge.calls, 1);
- const second = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-score-second', requestedBy: 'test' });
- await waitForTerminal(service, second.id);
- assert.equal(judge.calls, 1, 'same source/rubric/model/prompt must reuse the stored AI score');
- const beforeForce = await repository.listCurrentScores('demashi');
- 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' });
- await waitForTerminal(service, forced.id);
- assert.equal(judge.calls, 2, 'force must invoke the model even when the input fingerprint is unchanged');
- const afterForce = await repository.listCurrentScores('demashi');
- assert.equal(afterForce.length, beforeForce.length, 'force must overwrite the two current slots instead of appending history');
- assert.equal(afterForce.filter((item) => item.scoreKind === 'hybrid_ai').length, 1);
- });
- test('AI output parser normalizes a scalar evidence ID without relaxing rubric completeness', () => {
- const content = JSON.stringify({
- assessments: LISTING_AI_CRITERIA.map((criterion) => ({ criterionId: criterion.id, level: 'pass', evidenceIds: 'title', reason: '有直接证据', confidence: 0.8 })),
- summary: '完成', suggestions: [],
- });
- const parsed = parseListingAiScoreOutput(content);
- assert.deepEqual(parsed?.assessments[0]?.evidenceIds, ['title']);
- });
- test('image-only descriptions are scored from observable asset structure without semantic AI criteria', async () => {
- const imageOnly = { ...source, descriptions: { desktopHtml: '<p><img src="https://img.test/detail.jpg"></p>', mobileHtml: '<img src="https://img.test/detail-mobile.jpg">' } };
- const output = await new StableAiJudge().score();
- const result = composeListingAiScore({ source: imageOnly, baseline: scoreListing(imageOnly), output, model: 'test-model', now: '2026-08-24T00:00:00.000Z' });
- const description = result.dimensions.find((item) => item.dimension === 'description');
- assert.equal(description?.evidence.some((item) => item.ruleId.startsWith('ai.')), false);
- assert.equal(description?.score, 15);
- assert.equal(result.knownOverallMaxScore, 100);
- });
- test('an observable empty product adword receives a numeric score instead of unknown', async () => {
- const skuOnly = { ...source, marketing: { ...source.marketing!, adword: null, sellingPoints: source.marketing!.sellingPoints.filter((item) => item.source === 'sku_short_title') } };
- const output = await new StableAiJudge().score();
- const result = composeListingAiScore({ source: skuOnly, baseline: scoreListing(skuOnly), output, model: 'test-model', now: '2026-08-24T00:00:00.000Z' });
- const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
- assert.equal(sellingPoints?.evidence.some((item) => item.outcome === 'unknown'), false);
- assert.equal(typeof sellingPoints?.score, 'number');
- });
- test('AI failures remain on the job and do not overwrite the current formal score', async () => {
- const repository = new InMemoryListingAiRepository([source]);
- const provider: ListingAiScoringProvider = { configured: true, model: 'failing-model', score: async () => { throw new Error('ai_upstream_test'); } };
- const service = new ListingAiService(repository, provider, () => new Date('2026-08-24T00:00:00.000Z'), 1, 10);
- const rulesJob = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [source.productId] }, includeAiSuggestions: false, idempotencyKey: 'rules-before-ai-failure', requestedBy: 'test' });
- await waitForTerminal(service, rulesJob.id);
- 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' });
- await waitForTerminal(service, job.id);
- assert.equal(await repository.getCurrentScore('demashi', source.productId, 'formal_ai'), null);
- assert.equal((await repository.getCurrentScore('demashi', source.productId, 'rule_precheck'))?.scoreKind, 'rules');
- assert.equal((await repository.getJobItems('demashi', job.id))[0]?.status, 'failed');
- });
- test('VOC and unverified category requirements do not create partial V7 results', async () => {
- const partialSource = { ...source, productId: 'ai-partial', sourceHash: 'f'.repeat(64), vocEvidence: [], categoryContext: { ...source.categoryContext!, requiredSpecificationNames: [], ruleVersion: null } };
- const repository = new InMemoryListingAiRepository([partialSource]);
- const judge = new StableAiJudge();
- const service = new ListingAiService(repository, judge, () => new Date('2026-08-24T01:00:00.000Z'), 1, 10);
- const job = await service.enqueueScoreJob({ workspaceId: 'demashi', platform: 'jd', scope: { mode: 'selected', productIds: [partialSource.productId] }, includeAiSuggestions: true, idempotencyKey: 'ai-partial-evidence', requestedBy: 'test' });
- await waitForTerminal(service, job.id);
- const result = await repository.getCurrentScore('demashi', partialSource.productId, 'formal_ai');
- assert.equal(judge.calls, 1);
- assert.equal(typeof result?.overallScore, 'number');
- assert.equal(result?.dimensions.flatMap((item) => item.evidence).some((item) => item.ruleId.includes('voc') || item.ruleId.includes('category_completeness')), false);
- assert.equal((await repository.getJob('demashi', job.id))?.status, 'completed');
- const item = (await repository.getJobItems('demashi', job.id))[0];
- assert.equal(item?.errorCode, null);
- assert.deepEqual(item?.statusReasonCodes, []);
- });
|