benchmark-jd-voc-migration.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
  1. import { pathToFileURL } from 'node:url';
  2. import { resolve } from 'node:path';
  3. import type { JdVocRuleContext, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  4. import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js';
  5. const labUrl = (process.env.LAB_URL ?? 'http://127.0.0.1:4410').replace(/\/+$/, '');
  6. const limit = Math.max(1, Math.min(100, Number(process.env.BENCHMARK_LIMIT ?? 100)));
  7. const originalUrl = pathToFileURL(resolve(process.cwd(), '..', 'listing评分规则测试', 'public', 'rubric-engine.js')).href;
  8. const original = await import(originalUrl) as {
  9. normalizeListing(input: unknown): unknown;
  10. buildVocConcerns(input: unknown): unknown;
  11. scoreListing(input: unknown, concerns: unknown): { observableScore: number | null; coverage: number; dimensions: Array<{ key: string; score: number | null; coverage: number }> };
  12. };
  13. async function json<T>(url: string): Promise<T> {
  14. const response = await fetch(url, { signal: AbortSignal.timeout(90_000) });
  15. if (!response.ok) throw new Error(`http_${response.status}`);
  16. return response.json() as Promise<T>;
  17. }
  18. const catalog = await json<{ items: Array<{ productId: string }> }>(`${labUrl}/lab/catalog?limit=${limit}`);
  19. const rows: Array<{ productId: string; scoreDelta: number | null; coverageDelta: number; dimensionMismatches: number; error: string | null }> = [];
  20. for (const item of catalog.items.slice(0, limit)) {
  21. try {
  22. 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)}`);
  23. const listing = original.normalizeListing(detail);
  24. const expected = original.scoreListing(listing, original.buildVocConcerns(listing));
  25. const context: JdVocRuleContext = {
  26. 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 })),
  27. categoryVocEvidence: (detail.source.vocEvidence ?? []).map((evidence) => ({ id: evidence.id, source: 'category-voc', text: evidence.text, observedAt: evidence.collectedAt })),
  28. competitors: detail.competitors ?? [],
  29. competitorBasis: detail.competitorBasis === 'category-inferred' ? 'category-inferred' : detail.competitors?.length ? 'formal' : 'none',
  30. };
  31. const actual = scoreJdVocRules(detail.source, context, { id: 'benchmark', now: '2026-09-05T00:00:00.000Z' });
  32. const expectedByKey = new Map(expected.dimensions.map((dimension) => [dimension.key, dimension]));
  33. const dimensionMismatches = actual.dimensions.filter((dimension) => {
  34. const target = expectedByKey.get(dimension.key);
  35. return !target || target.score !== dimension.score || target.coverage !== dimension.coverage;
  36. }).length;
  37. 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 });
  38. } catch (error) { rows.push({ productId: item.productId, scoreDelta: null, coverageDelta: 0, dimensionMismatches: 6, error: error instanceof Error ? error.message.slice(0, 120) : 'benchmark_failed' }); }
  39. }
  40. const report = {
  41. rubricVersion: 'jd-voc-v0.5', requested: rows.length, completed: rows.filter((row) => !row.error).length,
  42. exactScoreMatches: rows.filter((row) => row.scoreDelta === 0).length,
  43. exactCoverageMatches: rows.filter((row) => row.coverageDelta === 0).length,
  44. rowsWithDimensionMismatch: rows.filter((row) => row.dimensionMismatches > 0).length,
  45. failed: rows.filter((row) => row.error).length,
  46. };
  47. console.log(JSON.stringify(report, null, 2));
  48. if (report.failed || report.exactScoreMatches !== report.requested || report.exactCoverageMatches !== report.requested || report.rowsWithDimensionMismatch) process.exitCode = 2;