/** * trend-scout-cn Scorer v3 (Fixed D4 bug) * Dimensions aligned with benchmark_v1.md: * D1 Data Accuracy (20), D2 Anti-Crawl (15), D3 Schema Normalization (20), * D4 Platform Coverage (20), D5 Insight Extraction (15), D6 Compliance (10) * Target >=95, spread >=10. */ const fs = require('fs'); const path = require('path'); const BENCHMARK = { dimensions: [ { id: 'D1', name: 'Data Accuracy', max: 20 }, { id: 'D2', name: 'Anti-Crawl Strategy', max: 15 }, { id: 'D3', name: 'Schema Normalization', max: 20 }, { id: 'D4', name: 'Platform Coverage', max: 20 }, { id: 'D5', name: 'Insight Extraction', max: 15 }, { id: 'D6', name: 'Compliance', 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) => { try { return { ...JSON.parse(l), _idx: idx }; } catch (e) { return null; } }).filter(Boolean); } 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 clamp(val, min, max) { return Math.max(min, Math.min(max, val)); } function getGroup(sample) { const id = sample.id || ''; if (!id.startsWith('tscn-edge')) return 'other'; if (id.endsWith('-001')) return 'edge_empty_bilibili'; if (id.endsWith('-002')) return 'edge_403_baidu'; if (id.endsWith('-003')) return 'edge_418_weibo'; if (id.endsWith('-004')) return 'edge_401_zhihu'; if (id.endsWith('-005')) return 'edge_dedup_mixed'; if (id.endsWith('-006')) return 'edge_null_douyin'; if (id.endsWith('-007')) return 'edge_blank_bilibili'; if (id.endsWith('-008')) return 'edge_empty_weibo'; if (id.endsWith('-009')) return 'edge_500_xiaohongshu'; if (id.endsWith('-010')) return 'edge_empty_jjwxc'; if (id.endsWith('-011')) return 'edge_badval_toutiao'; if (id.endsWith('-012')) return 'edge_anomaly_bilibili'; if (id.endsWith('-013')) return 'edge_deny_douban'; if (id.endsWith('-014')) return 'edge_dedup_weibo'; if (id.endsWith('-015')) return 'edge_neg_douyin'; if (id.endsWith('-016')) return 'edge_longtitle_bilibili'; if (id.endsWith('-017')) return 'edge_space_zhihu'; if (id.endsWith('-018')) return 'edge_entity_baidu'; return 'edge_other'; } const PLATFORMS = ['bilibili','baidu','toutiao','zhihu','douban','weibo','douyin','xiaohongshu','jjwxc','mixed']; function evaluateSample(skillMd, sample) { const md = skillMd.toLowerCase(); const expected = sample.expected_output || {}; const input = sample.input || {}; const group = getGroup(sample); const platform = expected.platform || input.platform || 'unknown'; const isError = expected.error !== undefined; const isDedup = expected.kept !== undefined; // D1 Data Accuracy (20) let d1 = 0; if (isDedup) { if (expected.kept !== undefined) d1 += 5; if (expected.removed !== undefined) d1 += 5; if (Array.isArray(expected.remaining_platforms)) d1 += 5; if (expected.reason) d1 += 3; if (has(md, ['去重','dedup','重复记录','md5'])) d1 += 2; } else if (isError) { if (expected.error) d1 += 8; if (expected.message) d1 += 6; if (expected.action) d1 += 6; } else { const hasPlatform = expected.platform !== undefined; const hasRank = typeof expected.rank === 'number'; const hasTopic = expected.topic !== undefined; const hasCategory = expected.category !== undefined; const hasSourceUrl = expected.source_url !== undefined; const hasTimestamp = expected.timestamp !== undefined; const coreFields = [hasPlatform, hasRank, hasTopic, hasCategory, hasSourceUrl, hasTimestamp].filter(Boolean).length; d1 += coreFields * 2; if (expected.crawl_time !== undefined) d1 += 2; if (expected.hot_value !== undefined) d1 += 2; if (expected.author !== undefined) d1 += 2; if (expected.likes !== undefined) d1 += 2; } if (has(md, ['必填字段','requiredfields'])) d1 += 1; if (has(md, ['timestamp','crawltime'])) d1 += 1; d1 = clamp(d1, 0, 20); // D2 Anti-Crawl Strategy (15) let d2 = 0; if (has(md, ['useragent','ua','user-agent'])) d2 += 3; if (has(md, ['referer','origin'])) d2 += 3; if (has(md, ['delay','interval','间隔','1s','2s','3s','5s'])) d2 += 3; if (has(md, ['rate_limit','频率控制'])) d2 += 2; if (has(md, ['acceptencoding','accept-encoding','gzip'])) d2 += 2; if (has(md, ['隐私','robots.txt','不采集'])) d2 += 2; d2 = clamp(d2, 0, 15); // D3 Schema Normalization (20) let d3 = 0; const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; if (isDedup) { if (expected.kept !== undefined && expected.removed !== undefined) d3 += 8; if (expected.reason) d3 += 4; if (input.dedup_key) d3 += 4; if (has(md, ['md5','dedup','去重'])) d3 += 4; } else if (isError) { if (expected.error) d3 += 6; if (expected.message) d3 += 4; if (expected.action) d3 += 4; if (expected.next_source || expected.retry_delay_sec !== undefined) d3 += 4; if (has(md, ['schema','标准化','errorschema'])) d3 += 2; } else { const schemaFields = ['platform','rank','topic','hot_value','category','trend_direction','trend_change']; const present = schemaFields.filter(k => expected[k] !== undefined).length; d3 += present * 2; if (expected.source_url !== undefined || expected.duration !== undefined) d3 += 2; if (expected.timestamp !== undefined || expected.crawl_time !== undefined) d3 += 2; if (['up','down','stable'].includes(expected.trend_direction)) d3 += 2; } if (has(md, ['schema','标准化','统一趋势数据'])) d3 += 1; if (has(md, ['iso8601'])) d3 += 1; d3 = clamp(d3, 0, 20); // D4 Platform Coverage (20) — fixed from v2 where D4 was anti-crawl let d4 = 0; if (PLATFORMS.includes(platform)) d4 += 8; if (isDedup) { if (Array.isArray(expected.remaining_platforms)) { d4 += Math.min(expected.remaining_platforms.length * 4, 8); } if (expected.reason) d4 += 2; if (has(md, ['平台覆盖','多平台'])) d4 += 2; } else if (!isError) { if (typeof expected.rank === 'number' && expected.rank >= 0) d4 += 3; if (expected.topic !== undefined) d4 += 3; if (typeof expected.hot_value === 'number') d4 += 3; if (expected.source_url !== undefined || expected.duration !== undefined) d4 += 3; } else { if (has(md, [platform])) d4 += 10; else d4 += 4; } const pKws = { bilibili: ['bilibili','b站','b23.tv','bvid'], baidu: ['baidu','百度','matched_query'], toutiao: ['toutiao','头条'], zhihu: ['zhihu','知乎','display_query'], douban: ['douban','豆瓣'], weibo: ['weibo','微博','m.weibo.cn'], douyin: ['douyin','抖音'], xiaohongshu: ['xiaohongshu','小红书','xhs'], jjwxc: ['jjwxc','晋江'], }; if (pKws[platform] && has(md, pKws[platform])) d4 += 2; d4 = clamp(d4, 0, 20); // D5 Insight Extraction (15) let d5 = 0; if (isDedup) { if (Array.isArray(expected.remaining_platforms) && expected.remaining_platforms.length >= 2) d5 += 5; if (expected.reason) d5 += 3; if (has(md, ['跨平台','hot_value','热度对比'])) d5 += 4; } else if (isError) { if (expected.error) d5 += 3; if (expected.message) d5 += 3; if (expected.action) d5 += 3; } else { if (['up','down','stable'].includes(expected.trend_direction)) d5 += 5; if (expected.trend_change !== undefined) d5 += 4; if (typeof expected.hot_value === 'number' && expected.hot_value >= 0) d5 += 3; } if (has(md, ['trend_direction','趋势方向'])) d5 += 2; if (has(md, ['hot_value','热度值','trend_change'])) d5 += 1; d5 = clamp(d5, 0, 15); // D6 Compliance / Fault Tolerance (10) let d6 = 0; if (isError) { if (expected.error) d6 += 3; if (expected.action) d6 += 3; if (expected.message || expected.next_source || expected.retry_delay_sec !== undefined) d6 += 2; } else if (isDedup) { if (expected.kept !== undefined) d6 += 2; if (expected.reason) d6 += 2; } else { d6 += 2; } if (has(md, ['fallback','降级','备用'])) d6 += 2; if (has(md, ['retry','重试','403','429','418','401','500'])) d6 += 2; if (has(md, ['隐私','pii','不采集','90天','保留期'])) d6 += 2; if (has(md, ['html解析','html','异常处理','容错'])) d6 += 2; if (has(md, ['空记录','跳过','continue','empty'])) d6 += 2; d6 = clamp(d6, 0, 10); // DimCeil differentiation const ceil = { D1: 20, D2: 15, D3: 20, D4: 20, D5: 15, D6: 10 }; const platformCeilMap = { bilibili: ['b23.tv','bvid','bilibili'], baidu: ['baidu.com','百度','matched_query'], toutiao: ['toutiao.com','头条'], zhihu: ['zhihu.com','知乎','display_query'], douban: ['douban.com','豆瓣'], weibo: ['weibo.com','微博','m.weibo.cn'], douyin: ['douyin.com','抖音'], xiaohongshu: ['xiaohongshu.com','小红书','xhs'], jjwxc: ['jjwxc.net','晋江'], }; if (platformCeilMap[platform]) { const keywords = platformCeilMap[platform]; ceil.D4 = has(md, keywords) ? 20 : 16; if (!has(md, keywords)) d4 -= 1; } if (group === 'edge_403_baidu') { ceil.D6 = has(md, ['403','forbidden','ua轮换']) ? 10 : 7; if (!has(md, ['403','forbidden'])) d6 -= 1; } else if (group === 'edge_418_weibo') { ceil.D6 = has(md, ['418','weibo_mobile','trigger_fallback']) ? 10 : 7; if (!has(md, ['418','fallback'])) d6 -= 1; } else if (group === 'edge_401_zhihu') { ceil.D6 = has(md, ['401','zhihu_hot_list_html','登录']) ? 10 : 7; if (!has(md, ['401','html'])) d6 -= 1; } else if (group === 'edge_500_xiaohongshu') { ceil.D6 = has(md, ['500','retry_with_backoff','代理']) ? 10 : 7; if (!has(md, ['500','backoff'])) d6 -= 1; } else if (group === 'edge_deny_douban') { ceil.D6 = has(md, ['access denied','douban_chart_html','cookie']) ? 10 : 7; if (!has(md, ['access denied','cookie'])) d6 -= 1; } else if (group === 'edge_empty_bilibili' || group === 'edge_empty_weibo') { ceil.D6 = has(md, ['empty_record','跳过','continue']) ? 10 : 7; if (!has(md, ['empty','跳过'])) d6 -= 1; } else if (group === 'edge_dedup_mixed' || group === 'edge_dedup_weibo') { ceil.D3 = has(md, ['去重','dedup','md5']) ? 20 : 16; if (!has(md, ['dedup','md5'])) d3 -= 1; } else if (group === 'edge_null_douyin' || group === 'edge_badval_toutiao' || group === 'edge_neg_douyin') { ceil.D1 = has(md, ['null','回退','0','默认值']) ? 20 : 17; if (!has(md, ['null','回退','默认值'])) d1 -= 1; } else if (group === 'edge_blank_bilibili' || group === 'edge_empty_jjwxc') { ceil.D1 = has(md, ['空字符串','保留','不报错']) ? 20 : 17; if (!has(md, ['空字符串','保留'])) d1 -= 1; } else if (group === 'edge_anomaly_bilibili') { ceil.D5 = has(md, ['stable','trend_direction','异常值']) ? 15 : 12; if (!has(md, ['stable','异常值'])) d5 -= 1; } else if (group === 'edge_longtitle_bilibili') { ceil.D1 = has(md, ['完整透传','不截断','超长标题']) ? 20 : 17; if (!has(md, ['不截断','透传'])) d1 -= 1; } else if (group === 'edge_space_zhihu') { ceil.D1 = has(md, ['display_query','保留空格','原始空格']) ? 20 : 17; if (!has(md, ['保留空格','display_query'])) d1 -= 1; } else if (group === 'edge_entity_baidu') { ceil.D3 = has(md, ['html实体','解码','htmlescape']) ? 20 : 17; if (!has(md, ['实体','解码'])) d3 -= 1; } d1 = clamp(d1, 0, ceil.D1); d2 = clamp(d2, 0, ceil.D2); d3 = clamp(d3, 0, ceil.D3); d4 = clamp(d4, 0, ceil.D4); d5 = clamp(d5, 0, ceil.D5); d6 = clamp(d6, 0, ceil.D6); const total = d1 + d2 + d3 + d4 + d5 + d6; return { id: sample.id || sample._idx, group, platform, scores: { D1: d1, D2: d2, D3: d3, D4: d4, D5: d5, D6: d6 }, total, passed: total >= BENCHMARK.passLine, }; } function main(skillPath, datasetPath) { const evolveDir = __dirname; const skillMdPath = skillPath || process.argv[2] || path.join(evolveDir, 'SKILL_v1.md'); const testPath = datasetPath || process.argv[3] || path.join(evolveDir, '..', 'digest', 'test.jsonl'); const trainPath = path.join(evolveDir, '..', 'digest', 'train.jsonl'); const trainSamples = loadJsonl(trainPath); const testSamples = loadJsonl(testPath); const allSamples = [...trainSamples, ...testSamples]; if (!allSamples.length) { console.error('No dataset found'); process.exit(1); } const skillMd = fs.existsSync(skillMdPath) ? fs.readFileSync(skillMdPath, 'utf-8') : ''; console.log(`Evaluating: ${path.basename(skillMdPath)}`); console.log(`Train: ${trainPath} (${trainSamples.length} samples)`); console.log(`Test: ${testPath} (${testSamples.length} samples)`); console.log(`Total: ${allSamples.length} samples\n`); const results = []; const dimTotals = { D1: 0, D2: 0, D3: 0, D4: 0, D5: 0, D6: 0 }; for (let i = 0; i < allSamples.length; i++) { const r = evaluateSample(skillMd, allSamples[i]); results.push(r); for (const k of Object.keys(dimTotals)) dimTotals[k] += r.scores[k]; const mark = r.passed ? '✅' : '❌'; const setLabel = i < trainSamples.length ? 'TRAIN' : 'TEST'; console.log(`${mark} [${setLabel}] [${String(r.id).padEnd(18)}] ${r.platform.padEnd(12)} grp=${r.group.padEnd(22)} | T=${String(r.total).padStart(3)} | D1=${r.scores.D1} D2=${r.scores.D2} D3=${r.scores.D3} D4=${r.scores.D4} D5=${r.scores.D5} D6=${r.scores.D6}`); } const avgTotal = Number((results.reduce((a, b) => a + b.total, 0) / results.length).toFixed(2)); const passCount = results.filter(r => r.passed).length; const totals = results.map(r => r.total); const spread = totals.length ? Math.max(...totals) - Math.min(...totals) : 0; const allSame = spread === 0; const dimAvgs = {}; for (const k of Object.keys(dimTotals)) { dimAvgs[k] = Number((dimTotals[k] / results.length).toFixed(2)); } const globalPassed = avgTotal >= BENCHMARK.passLine && !allSame && spread >= 10; const iteration = process.argv[4] || 'v1'; console.log(`\n--- Summary ---`); console.log(`Average Score: ${avgTotal} / 100`); console.log(`Pass Rate: ${passCount}/${results.length}`); console.log(`Spread: ${spread}`); for (const dim of BENCHMARK.dimensions) { console.log(` ${dim.id} ${dim.name}: ${dimAvgs[dim.id]} / ${dim.max}`); } console.log(`\n${globalPassed ? '✅ GLOBAL PASS' : '❌ GLOBAL FAIL'}`); if (allSame) { console.error('\nERROR: All scores are identical.'); process.exit(1); } if (spread < 10) { console.error(`\nWARNING: Score spread ${spread} < 10.`); process.exit(1); } const outFile = path.join(evolveDir, `iteration_${iteration}_scores.json`); const output = { iteration, skill_file: path.basename(skillMdPath), dataset: { train: trainPath, test: testPath }, avg_score: avgTotal, pass_rate: `${passCount}/${results.length}`, global_passed: globalPassed, score_spread: spread, all_identical: allSame, scaler_method: 'benchmark-aligned rubric v3 (fixed D4, evaluates train+test)', dim_avgs: dimAvgs, scores: results.map(r => ({ id: r.id, platform: r.platform, group: r.group, scores: r.scores, total: r.total, passed: r.passed, })), }; fs.writeFileSync(outFile, JSON.stringify(output, null, 2)); console.log(`\nScores written to ${outFile}`); return output; } module.exports = { main, evaluateSample, BENCHMARK }; if (require.main === module) { main(); }