/** * Digester Scorer v5 — Honest Expected-Output-Based Rubric Scorer * 基于 test.jsonl 的 expected_output 逐 case 构建细粒度 rubric, * 确保评分真实差异化(禁止所有样本同分,禁止 coverage-based 给所有 case 打近满分)。 * * 评分逻辑: * - 每个 case 提取 4-8 个关键要素(来自 expected_output 和 grader_hints) * - coverage = matched / total_elements * - baseScore = 55 + coverage * 45 → [55, 100] * - 各维度得分 = baseScore * (权重比例) * dimension_multiplier * - 主导维度(dominant dim) multiplier = 1.15,其余 = 1.0 * - 缺失关键要素时扣分更显著,确保真实 spread */ const fs = require('fs'); const path = require('path'); const BENCHMARK = { dimensions: [ { id: 'D1', name: '数据清洗与结构化抽取', weight: 0.20, max: 20 }, { id: 'D2', name: '评分维度设计与权重分配', weight: 0.20, max: 20 }, { id: 'D3', name: '标注质量控制与 IAA', weight: 0.20, max: 20 }, { id: 'D4', name: '数据集拆分与分布均衡', weight: 0.15, max: 15 }, { id: 'D5', name: '行业基线计算与统计严谨性', weight: 0.15, max: 15 }, { id: 'D6', name: '合规与伦理意识', weight: 0.10, 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) => { const parsed = JSON.parse(l); return { ...parsed, id: idx + 1 }; }); } function norm(text) { return (text || '').toLowerCase() .replace(/[\s,;.::\/\-_=+()\[\]{}|"']/g, ''); } function has(skill, kws) { const s = norm(skill); return kws.some(k => s.includes(norm(k))); } function hasAll(skill, kws) { const s = norm(skill); return kws.every(k => s.includes(norm(k))); } function countMatches(skill, kws) { const s = norm(skill); return kws.filter(k => s.includes(norm(k))).length; } function evaluateCase(skillMd, c, idx) { const s = skillMd; const inp = (c.input || '').toLowerCase(); const expected = c.expected_output || {}; const hints = (c.grader_hints || '').toLowerCase(); let reasoning = ''; let elements = []; let dominantDims = []; // ===== CASE 1: 设计6维度 benchmark ===== if (inp.includes('知识消化专家') && inp.includes('6维度')) { dominantDims = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6']; elements = [ { name: '6维度', check: has(s, ['6维度', '六维度', '六个维度', 'd1-d6']) }, { name: '权重=100%', check: has(s, ['权重之和', '100%', '等于']) }, { name: 'target>=95', check: has(s, ['目标分数', 'target_score', '≥95', '>=95', '95分', '合格线']) }, { name: 'ceiling设计', check: has(s, ['天花板', 'ceiling', '满分泛滥', '极难达到']) }, { name: 'grader_hints', check: has(s, ['grader_hints', '评分提示', '易错点']) }, { name: '统计方法', check: has(s, ['wilcoxon', 'friedman', 'bootstrap', '统计']) }, { name: '合规', check: has(s, ['robots.txt', 'pii', 'rate_limit', '合规']) }, ]; } // ===== CASE 2: 权重问题分析 ===== else if (inp.includes('权重') && inp.includes('10%,20%,15%')) { dominantDims = ['D2']; elements = [ { name: '指出D6权重偏低', check: has(s, ['d6', '权重偏低', '权重分配', '不均衡', '15%']) || has(s, ['d6权重', '偏低']) }, { name: '确认权重之和=100%', check: has(s, ['权重之和', '100%', '等于']) }, { name: '给出调整建议', check: has(s, ['建议', '调整', '提升', '匀出', '改进']) }, { name: '统计维度意识', check: has(s, ['统计', 'd5', '方法论', 'wilcoxon']) }, ]; } // ===== CASE 3: industry_avg_score 计算 ===== else if (inp.includes('industry_avg_score')) { dominantDims = ['D5']; elements = [ { name: 'industry_avg_score', check: has(s, ['industry_avg_score', '行业基准', '行业平均分', '基线']) }, { name: '难度调整', check: has(s, ['难度调整', 'difficulty', '调整', '难度系数']), critical: true }, { name: '合理区间85-90', check: has(s, ['85', '90', '区间', '合理区间']) }, { name: 'random+strong baseline', check: hasAll(s, ['random baseline', 'strong baseline']) || has(s, ['wilcoxon', 'bootstrap', '基线定义']) }, { name: '计算依据', check: has(s, ['计算依据', 'calculation', '聚合', '加权']) }, ]; } // ===== CASE 4: JSON Lines 格式 ===== else if (inp.includes('json 数组格式') || inp.includes('json lines')) { dominantDims = ['D6']; elements = [ { name: '识别格式错误', check: hasAll(s, ['json lines', 'jsonl', '数组', '格式错误']) || hasAll(s, ['jsonl', '外层数组', '逗号']) || has(s, ['e1']) }, { name: '说明正确格式', check: hasAll(s, ['每行一个', '独立json', '无逗号']) || hasAll(s, ['每行一个', 'json对象', '无外层']) || has(s, ['json lines', '每行一个']) }, { name: '量化下游影响', check: has(s, ['下游', '解析器', '影响', 'evolver', '无法读取']) }, { name: '给出修复步骤', check: (has(s, ['修复', '转换', '去掉', '修复步骤', 'e1']) && has(s, ['去掉', '无逗号'])) }, ]; } // ===== CASE 5: Krippendorff's Alpha ===== else if (inp.includes("krippendorff") || inp.includes("cohen's kappa")) { dominantDims = ['D3']; elements = [ { name: 'Krippendorff首选', check: hasAll(s, ["krippendorff's alpha", 'krippendorff', '首选']) || has(s, ['krippendorff', '首选']) }, { name: '任意数量标注员', check: has(s, ['任意数量标注员', '任意数量', '标注员']) }, { name: '支持缺失数据', check: has(s, ['缺失数据', '支持缺失']) }, { name: '多种数据类型', check: has(s, ['多种数据类型', '序数', '区间', '比率']) }, { name: 'Cohen局限性', check: hasAll(s, ["cohen's kappa", '2名', '不支持缺失']) || hasAll(s, ['cohen', '2名', '缺失']), critical: true }, { name: '结合digester场景', check: has(s, ['digester', '场景', '结论', '适合']) }, ]; } // ===== CASE 6: 评分标准通用化 ===== else if (inp.includes('评分标准') && inp.includes('通用描述') && inp.includes('具体示例')) { dominantDims = ['D2']; elements = [ { name: '指出通用模板问题', check: hasAll(s, ['通用', '模板', '缺乏', '耦合']) || hasAll(s, ['通用', '具体示例', '缺乏']) || has(s, ['通用描述', '缺乏具体']), critical: true }, { name: '影响一致性', check: has(s, ['一致性', '难以统一', '评分员', '评分器']) }, { name: 'ceiling_effect', check: has(s, ['天花板', '高分段', '泛滥', 'ceiling']) }, { name: '建议具体示例', check: hasAll(s, ['具体示例', '行为描述', '反例']) || has(s, ['具体示例', '反例', 'digester']) }, { name: 'd2相关', check: has(s, ['d2', '评分维度设计']) }, ]; } // ===== CASE 7: 500条爬取数据清洗 ===== else if (inp.includes('500条') && inp.includes('爬取') && inp.includes('清洗')) { dominantDims = ['D1']; elements = [ { name: 'URL去重', check: hasAll(s, ['去重', 'url']) || hasAll(s, ['url', '去重']) }, { name: 'SimHash/内容哈希', check: hasAll(s, ['去重', 'simhash']) || hasAll(s, ['去重', '内容哈希']) }, { name: '编码统一UTF-8/GBK', check: hasAll(s, ['utf-8', 'gbk', '编码', '转码']) || hasAll(s, ['utf-8', '编码', '转码']) }, { name: '字段缺失处理', check: hasAll(s, ['字段缺失', '补爬', '缺失率']) || hasAll(s, ['字段缺失', '缺失率']) }, { name: '重复率指标<5%', check: has(s, ['重复率', '< 5%', '< 1%', '4.6%']) }, { name: 'source_url溯源', check: hasAll(s, ['source_url', 'crawl_time', '溯源']) || hasAll(s, ['source_url', '溯源']) }, { name: 'schema提取', check: has(s, ['schema', '结构化抽取']) }, ]; } // ===== CASE 8: 时间敏感金融新闻拆分 ===== else if (inp.includes('时间敏感') && inp.includes('拆分方案')) { dominantDims = ['D4']; elements = [ { name: '时间切分temporal_cutoff', check: hasAll(s, ['temporal cutoff', '时间切分']) || has(s, ['temporal', 'cutoff', '时间切分']) }, { name: 'stratified_sampling', check: has(s, ['stratified sampling', '分层抽样']) }, { name: '分布偏差<5%', check: hasAll(s, ['分布', '偏差', '< 5%']) || hasAll(s, ['分布', '偏差', '5%']) }, { name: 'Vendi_Score', check: has(s, ['vendi score', '多样性', '嵌入']) }, { name: '泄露预防措施', check: hasAll(s, ['泄露', '时间墙', '实体去重']) || hasAll(s, ['泄露', 'n-gram', '实体去重']) }, { name: '拆分比例70/15/15或80/10/10', check: has(s, ['70/15/15', '80/10/10', 'train', 'validation', 'test']) }, { name: 'd4相关', check: has(s, ['d4', '数据集拆分']) }, ]; } else { dominantDims = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6']; elements = [{ name: 'fallback', check: true }]; reasoning = 'Fallback default (unknown case)'; } const matched = elements.filter(e => e.check).length; const totalElements = elements.length; let coverage = matched / totalElements; // Critical element penalty: each missing critical element reduces coverage by 0.10 (up to 0) const missingCritical = elements.filter(e => e.critical && !e.check).length; coverage = Math.max(0, coverage - missingCritical * 0.10); // Base score 55-100 let baseScore = 55 + coverage * 45; // For fully comprehensive case (all dimensions active), small bonus if coverage > 0.85 if (dominantDims.length >= 3 && coverage >= 0.85) { baseScore = Math.min(100, baseScore + 3); } // Distribute baseScore to dimensions const dimScores = {}; let sumRaw = 0; for (const dim of BENCHMARK.dimensions) { const isDominant = dominantDims.includes(dim.id); const mult = isDominant ? 1.15 : 1.0; const raw = baseScore * dim.weight * mult; dimScores[dim.id] = Math.min(dim.max, raw); sumRaw += dimScores[dim.id]; } // Normalize so total ≈ baseScore (allow small deviation if caps hit) const scaleFactor = baseScore / Math.max(1, sumRaw); let total = 0; for (const dim of BENCHMARK.dimensions) { dimScores[dim.id] = Math.min(dim.max, Math.round(dimScores[dim.id] * scaleFactor)); total += dimScores[dim.id]; } if (!reasoning) { reasoning = `Coverage=${matched}/${totalElements}${missingCritical > 0 ? `, missedCritical=${missingCritical}` : ''}`; } return { D1: dimScores.D1, D2: dimScores.D2, D3: dimScores.D3, D4: dimScores.D4, D5: dimScores.D5, D6: dimScores.D6, total, reasoning }; } function main(skillPath, datasetPath) { const evolveDir = __dirname; const testPath = datasetPath || path.join(evolveDir, '..', 'digest', 'test.jsonl'); const skillMdPath = skillPath || process.argv[2] || path.join(evolveDir, 'SKILL_v1.md'); if (!fs.existsSync(testPath)) { console.error('test.jsonl not found:', testPath); process.exit(1); } const cases = loadJsonl(testPath); const skillMd = fs.readFileSync(skillMdPath, 'utf-8'); console.log(`\nEvaluating SKILL.md: ${skillMdPath}`); console.log(`Dataset: ${testPath}`); console.log(`Cases: ${cases.length}\n`); const allEvals = []; for (let i = 0; i < cases.length; i++) { try { console.log(` Case ${i + 1}...`); const result = evaluateCase(skillMd, cases[i], i); allEvals.push(result); } catch (e) { console.error(`FATAL: Case ${i + 1} failed: ${e.message}`); process.exit(1); } } const scores = allEvals.map((sc, i) => { const c = cases[i]; const instruction = typeof c.input === 'string' ? c.input.slice(0, 160) : JSON.stringify(c.input || {}).slice(0, 160); return { id: c.id || `case-${String(i + 1).padStart(3, '0')}`, instruction, scores: { D1: sc.D1, D2: sc.D2, D3: sc.D3, D4: sc.D4, D5: sc.D5, D6: sc.D6 }, total: sc.total, passed: sc.total >= BENCHMARK.passLine, reasoning: sc.reasoning }; }); const avg = scores.reduce((a, b) => a + b.total, 0) / scores.length; const passed = scores.filter(s => s.passed).length; const totals = scores.map(s => s.total); const spread = Math.max(...totals) - Math.min(...totals); const allSame = spread === 0; const dimTotals = { D1: 0, D2: 0, D3: 0, D4: 0, D5: 0, D6: 0 }; for (const s of scores) { for (const k of Object.keys(dimTotals)) { dimTotals[k] += s.scores[k]; } } const dimAvgs = {}; for (const k of Object.keys(dimTotals)) { dimAvgs[k] = Number((dimTotals[k] / scores.length).toFixed(2)); } const globalPassed = avg >= BENCHMARK.passLine && !allSame && spread >= 10; const iteration = process.argv[4] || 'v1'; const result = { iteration, skill_file: path.basename(skillMdPath), dataset: testPath, avg_score: parseFloat(avg.toFixed(2)), pass_rate: `${passed}/${scores.length}`, global_passed: globalPassed, score_spread: spread, all_identical: allSame, scaler_method: 'expected-output rubric v5', dim_avgs: dimAvgs, scores }; const outFile = path.join(evolveDir, `iteration_${iteration}_scores.json`); fs.writeFileSync(outFile, JSON.stringify(result, null, 2)); console.log(`\nAvg: ${result.avg_score} | Pass: ${result.pass_rate} | Spread: ${spread} | Global: ${globalPassed}`); for (const dim of BENCHMARK.dimensions) { console.log(` ${dim.id} ${dim.name}: ${dimAvgs[dim.id]} / ${dim.max}`); } if (allSame) { console.error('\nERROR: All scores are identical.'); process.exit(1); } if (spread < 10) { console.error(`\nWARNING: Score spread ${spread} < 10.`); } return result; } module.exports = { main, evaluateCase, BENCHMARK }; if (require.main === module) { main(); }