| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283 |
- import assert from 'node:assert/strict';
- import test from 'node:test';
- import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
- import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
- import { composeJdVocHybridScore, jdVocAiEvidenceCatalog, jdVocAiPrompt, parseJdVocAiScoreOutput, validateJdVocAiOutput } from '../src/modules/listing-ai/scoring/jd-voc-ai-rubric.js';
- import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
- import { ListingAiService, type JdVocAiScoringProvider } from '../src/modules/listing-ai/listing-ai.service.js';
- const source = {
- id: 'source-ai', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: 'ai-jd-1', sourceHash: 'b'.repeat(64),
- title: '德玛仕 商用削皮机 500W', titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' }, categoryIds: ['food'],
- categoryContext: { names: ['削皮机'], coreTerms: ['削皮机'], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'peeler-v1' }, itemStatus: 'on_shelf',
- price: { jd: 1000, cost: null }, descriptions: { desktopHtml: '<p>商用</p>', mobileHtml: '<p>商用</p>' }, descriptionStructure: { observed: true, imageCount: 1, videoCount: 0, headingCount: 0, faqCandidateCount: 0 },
- 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',
- } as ListingSourceSnapshot;
- const baseline = scoreJdVocRules(source, { productReviews: [{ id: 'review-1', source: 'product-review', text: '削皮速度稳定' }] }, { id: 'baseline', now: '2026-09-04T08:00:00.000Z' });
- const validOutput = () => ({
- 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 })),
- suggestions: [{ title: '补充功率说明', action: '在卖点中说明已观测功率', evidenceIds: ['source.title'] }], summary: '完成语义复核',
- });
- test('JD-VOC AI parser requires five unique semantic dimensions and bounded scores', () => {
- const parsed = parseJdVocAiScoreOutput(JSON.stringify(validOutput()));
- assert.equal(parsed?.assessments.length, 5);
- assert.match(jdVocAiPrompt(), /search, voc, selling, facts, competitive/);
- assert.match(jdVocAiPrompt(), /media 图片维度由规则独占/);
- const duplicate = { ...validOutput(), assessments: validOutput().assessments.map((item, index) => index === 4 ? { ...item, dimension: 'search' as const } : item) };
- assert.equal(parseJdVocAiScoreOutput(JSON.stringify(duplicate)), null);
- });
- test('JD-VOC AI validation rejects unknown evidence and filters fabricated numeric suggestions', () => {
- const output = validOutput();
- const catalog = jdVocAiEvidenceCatalog(source, baseline);
- assert.throws(() => validateJdVocAiOutput({ ...output, assessments: output.assessments.map((item) => ({ ...item, evidenceIds: ['does-not-exist'] })) }, baseline, catalog, source), /evidence_invalid/);
- const filtered = validateJdVocAiOutput({ ...output, suggestions: [{ title: '补充 9999W', action: '宣称 9999W', evidenceIds: ['source.title'] }] }, baseline, catalog, source);
- assert.equal(filtered.suggestions.length, 0);
- });
- test('JD-VOC hybrid composition uses 65/35 and preserves blocked rule results', () => {
- const result = composeJdVocHybridScore({ baseline, output: validOutput(), source, model: 'gpt-4o-mini', now: '2026-09-04T08:01:00.000Z' });
- const search = result.dimensions.find((item) => item.key === 'search');
- const ruleSearch = baseline.dimensions.find((item) => item.key === 'search')?.score;
- assert.equal(search?.score, ruleSearch === null || ruleSearch === undefined ? null : Math.round((ruleSearch * 0.65 + 20 * 0.35) * 10) / 10);
- assert.equal(result.scoreKind, 'jd_voc_hybrid_ai');
- assert.equal(result.dimensions.find((item) => item.key === 'media')?.score, baseline.dimensions.find((item) => item.key === 'media')?.score);
- const blocked = scoreJdVocRules({ ...source, detailStatus: 'empty', title: null, features: [], attributes: [], images: [] });
- const blockedSource = { ...source, detailStatus: 'empty' as const, title: null, features: [], attributes: [], images: [] };
- const blockedOutput = { ...validOutput(), assessments: validOutput().assessments.map((item) => ({ ...item, evidenceIds: ['source.brand'] })) };
- assert.equal(composeJdVocHybridScore({ baseline: blocked, output: blockedOutput, source: blockedSource, model: 'gpt-4o-mini', now: '2026-09-04T08:01:00.000Z' }).overallScore, null);
- });
- test('JD-VOC score jobs persist AI slots, reuse cache, and keep rules on provider failure', async () => {
- class Provider implements JdVocAiScoringProvider {
- configured = true;
- model = 'gpt-4o-mini';
- calls = 0;
- fail = false;
- async score() { this.calls += 1; if (this.fail) throw new Error('test_ai_failure'); return validOutput(); }
- }
- class TrackingRepository extends InMemoryListingAiRepository {
- statuses:string[]=[];
- override async updateJobItem(item:Parameters<InMemoryListingAiRepository['updateJobItem']>[0]){this.statuses.push(item.status);return super.updateJobItem(item);}
- }
- const repository = new TrackingRepository([source]);
- const provider = new Provider();
- const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, provider);
- const run = async (key: string, policy: 'reuse' | 'force' = 'reuse') => {
- 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' });
- 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)); }
- assert.fail('JD-VOC job did not finish');
- };
- await run('jd-voc-ai-job-1');
- assert.deepEqual(repository.statuses.slice(0,3), ['rules_scored','ai_pending','partial']);
- assert.equal((await repository.getJdVocCurrentScore(source.workspaceId, source.productId, 'jd_voc_hybrid_ai'))?.aiReview?.status, 'completed');
- assert.equal(provider.calls, 1);
- await run('jd-voc-ai-job-2');
- assert.equal(provider.calls, 1, 'same source/model/prompt/context reuses the hybrid slot');
- provider.fail = true;
- const failed = await run('jd-voc-ai-job-3', 'force');
- assert.equal(failed.status, 'failed');
- assert.equal((await repository.getJdVocCurrentScore(source.workspaceId, source.productId, 'jd_voc_rules'))?.scoreKind, 'jd_voc_rules');
- });
|