import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; import type { JdVocRuleContext, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js'; import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js'; const labUrl = (process.env.LAB_URL ?? 'http://127.0.0.1:4410').replace(/\/+$/, ''); const limit = Math.max(1, Math.min(100, Number(process.env.BENCHMARK_LIMIT ?? 100))); const originalUrl = pathToFileURL(resolve(process.cwd(), '..', 'listing评分规则测试', 'public', 'rubric-engine.js')).href; const original = await import(originalUrl) as { normalizeListing(input: unknown): unknown; buildVocConcerns(input: unknown): unknown; scoreListing(input: unknown, concerns: unknown): { observableScore: number | null; coverage: number; dimensions: Array<{ key: string; score: number | null; coverage: number }> }; }; async function json(url: string): Promise { const response = await fetch(url, { signal: AbortSignal.timeout(90_000) }); if (!response.ok) throw new Error(`http_${response.status}`); return response.json() as Promise; } const catalog = await json<{ items: Array<{ productId: string }> }>(`${labUrl}/lab/catalog?limit=${limit}`); const rows: Array<{ productId: string; scoreDelta: number | null; coverageDelta: number; dimensionMismatches: number; error: string | null }> = []; for (const item of catalog.items.slice(0, limit)) { try { const detail = await json<{ source: ListingSourceSnapshot; reviews?: Array<{ id?: string; rating?: number; content?: string; date?: string }>; competitors?: JdVocRuleContext['competitors']; competitorBasis?: string }>(`${labUrl}/lab/products/${encodeURIComponent(item.productId)}`); const listing = original.normalizeListing(detail); const expected = original.scoreListing(listing, original.buildVocConcerns(listing)); const context: JdVocRuleContext = { productReviews: (detail.reviews ?? []).map((review, index) => ({ id: review.id ?? `review-${index + 1}`, source: 'product-review', text: review.content ?? '', rating: review.rating ?? null, observedAt: review.date ?? null })), categoryVocEvidence: (detail.source.vocEvidence ?? []).map((evidence) => ({ id: evidence.id, source: 'category-voc', text: evidence.text, observedAt: evidence.collectedAt })), competitors: detail.competitors ?? [], competitorBasis: detail.competitorBasis === 'category-inferred' ? 'category-inferred' : detail.competitors?.length ? 'formal' : 'none', }; const actual = scoreJdVocRules(detail.source, context, { id: 'benchmark', now: '2026-09-05T00:00:00.000Z' }); const expectedByKey = new Map(expected.dimensions.map((dimension) => [dimension.key, dimension])); const dimensionMismatches = actual.dimensions.filter((dimension) => { const target = expectedByKey.get(dimension.key); return !target || target.score !== dimension.score || target.coverage !== dimension.coverage; }).length; rows.push({ productId: item.productId, scoreDelta: expected.observableScore === null || actual.overallScore === null ? expected.observableScore === actual.overallScore ? 0 : null : Math.round((actual.overallScore - expected.observableScore) * 10) / 10, coverageDelta: Math.round((actual.coverage.percent - expected.coverage) * 10) / 10, dimensionMismatches, error: null }); } catch (error) { rows.push({ productId: item.productId, scoreDelta: null, coverageDelta: 0, dimensionMismatches: 6, error: error instanceof Error ? error.message.slice(0, 120) : 'benchmark_failed' }); } } const report = { rubricVersion: 'jd-voc-v0.5', requested: rows.length, completed: rows.filter((row) => !row.error).length, exactScoreMatches: rows.filter((row) => row.scoreDelta === 0).length, exactCoverageMatches: rows.filter((row) => row.coverageDelta === 0).length, rowsWithDimensionMismatch: rows.filter((row) => row.dimensionMismatches > 0).length, failed: rows.filter((row) => row.error).length, }; console.log(JSON.stringify(report, null, 2)); if (report.failed || report.exactScoreMatches !== report.requested || report.exactCoverageMatches !== report.requested || report.rowsWithDimensionMismatch) process.exitCode = 2;