scorer.js 7.91 KB
/**
 * Clickbait Copywriter Scorer
 * 基于 benchmark_v1.md 对 test.jsonl 进行自动化评分
 *
 * 用法: node scorer.js [skill_md_path]
 * 逻辑: 使用本地 claude CLI 对 test.jsonl 中的 reference outputs 直接评分
 */

const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const os = require('os');

const BENCHMARK = {
  dimensions: [
    { id: 'ctr', name: 'CTR优化能力', max: 20 },
    { id: 'emotion', name: '情绪调动精准度', max: 20 },
    { id: 'platform_fit', name: '平台适配正确性', max: 20 },
    { id: 'rhetoric', name: '修辞技法丰富度', max: 15 },
    { id: 'compliance', name: '合规安全性', max: 15 },
    { id: 'creativity', name: '创意差异化', max: 10 },
  ],
  passLine: 95,
};

function loadJsonl(filePath) {
  const text = fs.readFileSync(filePath, 'utf-8');
  return text
    .split('\n')
    .map((l) => l.trim())
    .filter(Boolean)
    .map((l, idx) => ({ ...JSON.parse(l), id: idx + 1 }));
}

function loadSkillMd(filePath) {
  if (!fs.existsSync(filePath)) return '';
  return fs.readFileSync(filePath, 'utf-8');
}

function buildScoringPrompt(cases) {
  const casesBlock = cases
    .map((c, i) => {
      const userContent = c.messages.find((m) => m.role === 'user')?.content || '';
      const assistantContent = c.messages.find((m) => m.role === 'assistant')?.content || '';
      return `--- CASE ${i + 1} ---\nUser Request:\n${userContent}\n\nAssistant Output:\n${assistantContent}\n`;
    })
    .join('\n');

  return `You are a senior cross-platform content marketing evaluator. Strictly score the following ${cases.length} clickbait copywriting outputs according to the 6 dimensions below. Each case has a user request and an assistant output.

Scoring Dimensions (total 100, passing line 95):
1. CTR Optimization (20 pts): Hook strength, information gap, 3-second attraction. Deductions: no clear hook (-5), info gap too vague (-4), first 10 chars no keyword/emotion (-3).
2. Emotional Precision (20 pts): Pain point resonance, emotion lever choice (curiosity/fear/greed/empathy/anger/social proof), immersion. Deductions: emotion mismatch with platform (-5), overuse of fear/anger causing反感 (-5), hollow emotion word stacking (-4).
3. Platform Fit (20 pts): Character limits, emoji/hashtag strategy, algorithm preference, red-line compliance. Deductions: banned superlatives (-8), Amazon with emoji or emotional hype (-6), character overflow (-4), wrong hashtag strategy (-3).
   Banned words (any = -8): 最, 第一, 绝对, 史上最强, 永久有效, 价格便宜; Amazon: Best Seller, Top Rated, #1 Choice, Amazing, On Sale, Free Shipping.
4. Rhetoric Richness (15 pts): Hyperbole, ellipsis, conflict, reversal, scarcity, identity label. 14-15 = 3+ techniques fused naturally; 11-13 = 2 techniques; 8-10 = 1 technique or forced.
5. Compliance Safety (15 pts): Zero superlatives, zero false info, no platform red lines. Health/medical claims must not overstep.
6. Creative Differentiation (10 pts): 10% Twist (unexpected element on proven formula), memorable, non-templatized. 9-10 = strong twist; 7-8 = weak twist; 5-6 = templatized.

Calibration examples from the benchmark standard:
- Example A (小红书): "怒花22w师从MGP,纠正四个误区" → ctr:19, emotion:18, platform_fit:19, rhetoric:14, compliance:15, creativity:9, total:94
- Example B (抖音): "我赌你没见过这么猛的祛痘方法" → ctr:19, emotion:19, platform_fit:18, rhetoric:13, compliance:14, creativity:8, total:91
- Example C (Amazon): "HydroCore Insulated Water Bottle — Stainless Steel, BPA-Free — 32 Oz — Keeps Drinks Cold 24 Hours..." → ctr:16, emotion:12, platform_fit:20, rhetoric:11, compliance:15, creativity:8, total:82

Please score consistently with these examples. Do not systematically deflate scores below the benchmark calibration.

${casesBlock}

Please output ONLY a JSON array with no markdown formatting. Each element must be:
{
  "case_id": number,
  "dimension_scores": {
    "ctr": 0-20,
    "emotion": 0-20,
    "platform_fit": 0-20,
    "rhetoric": 0-15,
    "compliance": 0-15,
    "creativity": 0-10
  },
  "total_score": 0-100,
  "passed": true/false,
  "feedback": "Brief: strongest point, weakest point, 1 actionable fix"
}

The response must be ONLY the JSON array, starting with [ and ending with ].`;
}

function callClaude(prompt) {
  return new Promise((resolve, reject) => {
    const tmpFile = path.join(os.tmpdir(), `cc_scorer_${Date.now()}.txt`);
    fs.writeFileSync(tmpFile, prompt, 'utf-8');
    execFile('bash', ['-c', `cat "${tmpFile}" | claude --print --dangerously-skip-permissions`], { timeout: 300000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
      try { fs.unlinkSync(tmpFile); } catch {}
      if (err) return reject(err);
      resolve(stdout.trim());
    });
  });
}

async function scoreCases(cases) {
  const prompt = buildScoringPrompt(cases);
  const raw = await callClaude(prompt);
  // Try to extract JSON array
  const match = raw.match(/\[[\s\S]*\]/);
  if (!match) throw new Error('No JSON array found in Claude response:\n' + raw);
  const parsed = JSON.parse(match[0]);
  return parsed;
}

async function main() {
  const evolveDir = __dirname;
  const testPath = process.argv[2] || path.join(evolveDir, '..', 'digest', 'test.jsonl');
  const skillMdPath = process.argv[3] || path.join(evolveDir, 'SKILL_v1.md');

  if (!fs.existsSync(testPath)) {
    console.error('test.jsonl not found:', testPath);
    process.exit(1);
  }

  const cases = loadJsonl(testPath);
  console.log(`\nEvaluating ${cases.length} test cases with Claude CLI...\n`);

  const scores = await scoreCases(cases);

  let totalScore = 0;
  let passCount = 0;
  const dimTotals = { ctr: 0, emotion: 0, platform_fit: 0, rhetoric: 0, compliance: 0, creativity: 0 };

  for (const s of scores) {
    totalScore += s.total_score;
    if (s.passed) passCount++;
    for (const k of Object.keys(dimTotals)) {
      dimTotals[k] += s.dimension_scores[k];
    }
    const status = s.passed ? '✅' : '❌';
    console.log(`${status} [Case ${s.case_id}] Total: ${s.total_score} | CTR:${s.dimension_scores.ctr} EM:${s.dimension_scores.emotion} PF:${s.dimension_scores.platform_fit} RH:${s.dimension_scores.rhetoric} CO:${s.dimension_scores.compliance} CR:${s.dimension_scores.creativity}`);
    console.log(`   Feedback: ${s.feedback}`);
  }

  const avgTotal = totalScore / cases.length;
  const dimAvgs = {};
  for (const k of Object.keys(dimTotals)) {
    dimAvgs[k] = dimTotals[k] / cases.length;
  }

  console.log(`\n--- Summary ---`);
  console.log(`Average Score: ${avgTotal.toFixed(2)} / 100`);
  console.log(`Pass Rate: ${passCount}/${cases.length}`);
  for (const dim of BENCHMARK.dimensions) {
    console.log(`  ${dim.name}: ${dimAvgs[dim.id].toFixed(2)} / ${dim.max}`);
  }

  const globalPassed = avgTotal >= BENCHMARK.passLine;
  console.log(`\n${globalPassed ? '✅ GLOBAL PASS' : '❌ GLOBAL FAIL'}`);

  const weakDims = BENCHMARK.dimensions
    .filter((d) => dimAvgs[d.id] < d.max * 0.85)
    .map((d) => d.name);
  if (weakDims.length) {
    console.log(`Weak dimensions (< 85% of max): ${weakDims.join(', ')}`);
  }

  // Write scores JSON
  const testBase = path.basename(testPath, '.jsonl');
  const outPath = path.join(evolveDir, `${testBase}_scores.json`);
  fs.writeFileSync(
    outPath,
    JSON.stringify(
      {
        iteration: Number(testBase.match(/iteration_(\d+)/)?.[1] || 1),
        skill_file: path.basename(skillMdPath),
        scores,
        summary: {
          avg_score: Number(avgTotal.toFixed(2)),
          pass_rate: `${passCount}/${cases.length}`,
          global_passed: globalPassed,
          weak_dimensions: weakDims,
        },
        dim_avgs: dimAvgs,
      },
      null,
      2
    )
  );
  console.log(`\nScores written to ${outPath}`);
  return { avgTotal, passCount, dimAvgs, globalPassed, scores };
}

module.exports = { main, BENCHMARK };

if (require.main === module) {
  main().catch((e) => {
    console.error(e);
    process.exit(1);
  });
}