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: `

${'299L大容量,一级能效,风冷无霜,适合便利店使用。'.repeat(20)}

`, mobileHtml: `

${'299L大容量,一级能效,风冷无霜。'.repeat(20)}

` }, 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 { 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 { 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: '

', mobileHtml: '' } }; 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, []); });