arena.js 17.5 KB
/**
 * Arena API Routes — Skill Arena 公开 + 管理接口
 */
const express = require('express');
const router = express.Router();
const taxonomy = require('../config/taxonomy');
const { ROLES, SCENES, PLATFORMS, autoTag, migrateOldScene } = taxonomy;
const config = require('../config');
const skillDb = require('../services/skill-db');
const benchmarkDB = require('../services/benchmark-db');
const benchmarkRunner = require('../services/benchmark-runner');

// ── 通用 async handler ──
const h = (fn) => (req, res) => {
  try { fn(req, res); }
  catch (e) { console.error(`[Arena] ${req.method} ${req.path}:`, e.message); res.status(500).json({ error: e.message }); }
};

// ═══════════════════════════════════════════
//  辅助函数
// ═══════════════════════════════════════════

function classifyLayer(skill) {
  if (skill.layer) return skill.layer;
  const deps = skill.dependencies || [];
  return deps.length === 0 ? 1 : deps.length <= 3 ? 2 : 3;
}

/** 给 Skill 补充 Arena 字段(三维标签 + ELO 默认值) */
function enrichArenaFields(skill) {
  migrateOldScene(skill);

  // 三维标签自动打标
  if (!skill.roles?.length || !skill.scenes?.length || !skill.platforms?.length) {
    const tags = autoTag(skill);
    if (!skill.roles?.length) skill.roles = tags.roles;
    if (!skill.scenes?.length) skill.scenes = tags.scenes;
    if (!skill.platforms?.length) skill.platforms = tags.platforms;
  }
  if (!skill.scene && skill.scenes?.length > 0) skill.scene = skill.scenes[0];
  if (!skill.layer) skill.layer = classifyLayer(skill);
  if (!skill.arena) {
    skill.arena = {
      elo: 1200, eloMargin: 50, rank: null, rankSpread: null,
      benchmarkRuns: 0, lastBenchmark: null,
      scores: { successRate: null, accuracy: null, speed: null, cost: null, robustness: null, completeness: null },
      securityScan: { status: 'pending', confidence: null, lastScan: null },
      highlighted: false, trending: false,
    };
  }
  if (!skill.author) skill.author = 'ArenaSK';
  if (!skill.license) skill.license = 'MIT';
  if (!skill.source) skill.source = 'local';
  if (!skill.tags) skill.tags = skill.keywords || [];
  return skill;
}

/** 计算排名(scene × platform 组合内 + 全局) */
function computeRanks(skills) {
  const groups = {};
  for (const s of skills) {
    for (const scene of (s.scenes || [s.scene || 'unknown'])) {
      for (const plat of (s.platforms || ['general'])) {
        const key = `${scene}_${plat}`;
        (groups[key] ||= []).push(s);
      }
    }
  }
  for (const group of Object.values(groups)) {
    group.sort((a, b) => (b.arena?.elo || 1200) - (a.arena?.elo || 1200));
    group.forEach((s, i) => { if (s.arena) s.arena.rank = i + 1; });
  }
  [...skills].sort((a, b) => (b.arena?.elo || 1200) - (a.arena?.elo || 1200))
    .forEach((s, i) => { if (s.arena) s.arena.globalRank = i + 1; });
  return skills;
}

/** 将搜索 DB row (含 arena join 字段) 转为 skill 对象 */
function searchRowToSkill(row) {
  const skill = skillDb.rowToSkill(row);
  if (row.elo != null) skill.arena = skillDb.rowToArena(row);
  return skill;
}

// ═══════════════════════════════════════════
//  公开 API (/api/open/arena/*)
// ═══════════════════════════════════════════

router.get('/taxonomy', h((req, res) => {
  const skills = skillDb.listSkills().map(enrichArenaFields);
  res.json({
    roles: ROLES.map(r => ({ ...r, totalSkills: skills.filter(s => (s.roles || []).includes(r.id)).length })),
    scenes: SCENES.map(scene => {
      const ss = skills.filter(s => (s.scenes || [s.scene]).includes(scene.id))
        .sort((a, b) => (b.arena?.elo || 1200) - (a.arena?.elo || 1200));
      return {
        ...scene, totalSkills: ss.length, topSkill: ss[0]?.skillName || null,
        topSkills: ss.slice(0, 5).map(s => ({
          skillName: s.skillName, elo: s.arena?.elo || 1200, platforms: s.platforms || [],
          description: (s.description || '').slice(0, 100), layer: s.layer, scenes: s.scenes || [],
        })),
      };
    }),
    platforms: PLATFORMS.map(p => ({ ...p, totalSkills: skills.filter(s => (s.platforms || []).includes(p.id)).length })),
    totalSkills: skills.length,
  });
}));

router.get('/scenes', h((req, res) => {
  const skills = skillDb.listSkills().map(enrichArenaFields);
  res.json({
    scenes: SCENES.map(scene => {
      const ss = skills.filter(s => (s.scenes || [s.scene]).includes(scene.id));
      return {
        ...scene, totalSkills: ss.length,
        l1Count: ss.filter(s => s.layer === 1).length,
        l2Count: ss.filter(s => s.layer === 2).length,
        l3Count: ss.filter(s => s.layer === 3).length,
        topSkill: ss.sort((a, b) => (b.arena?.elo || 1200) - (a.arena?.elo || 1200))[0]?.skillName || null,
      };
    }),
    totalSkills: skills.length,
    totalBenchmarkRuns: skills.reduce((sum, s) => sum + (s.arena?.benchmarkRuns || 0), 0),
  });
}));

router.get('/stats', h((req, res) => {
  const d = skillDb.getDb();
  const agg = d.prepare(`SELECT COALESCE(SUM(download_count),0) as totalDownloads, COALESCE(SUM(use_count),0) as totalUses, COALESCE(SUM(update_count),0) as totalUpdates, ROUND(AVG(current_score),1) as avgScore, MIN(current_score) as minScore, MAX(current_score) as maxScore FROM skills`).get();
  const elo = d.prepare(`SELECT ROUND(AVG(elo),0) as avgElo, MIN(elo) as minElo, MAX(elo) as maxElo, COALESCE(SUM(benchmark_runs),0) as totalBenchmarkRuns FROM skill_arena`).get();
  const bySrc = d.prepare("SELECT source, COUNT(*) as count FROM skills GROUP BY source ORDER BY count DESC").all();
  res.json({
    totalSkills: skillDb.countSkills(), l1Count: skillDb.countSkills({ layer: 1 }),
    l2Count: skillDb.countSkills({ layer: 2 }), l3Count: skillDb.countSkills({ layer: 3 }),
    totalDownloads: agg.totalDownloads, totalUses: agg.totalUses, totalUpdates: agg.totalUpdates,
    avgScore: agg.avgScore, scoreRange: { min: agg.minScore, max: agg.maxScore },
    avgElo: elo.avgElo, eloRange: { min: elo.minElo, max: elo.maxElo },
    totalBenchmarkRuns: elo.totalBenchmarkRuns, bySource: bySrc,
    scenes: SCENES.length, roles: ROLES.length, platforms: PLATFORMS.length,
    lastUpdated: new Date().toISOString(),
  });
}));

router.get('/leaderboard', h((req, res) => {
  const { role, scene, platform, layer, sort = 'elo', search } = req.query;
  const limit = Math.min(parseInt(req.query.limit) || 50, 200);
  const page = parseInt(req.query.page) || 1;
  const offset = (page - 1) * limit;

  let skills;
  if (search?.trim()) {
    // 搜索模式:FTS5 / LIKE
    const q = search.trim();
    const d = skillDb.getDb();
    const arenaJoin = `LEFT JOIN skill_arena a ON s.skill_id = a.skill_id`;
    const arenaFields = `a.elo, a.rank, a.benchmark_runs, a.highlighted, a.trending, a.score_success_rate, a.score_accuracy, a.score_speed, a.score_cost, a.score_robustness, a.score_completeness, a.global_rank, a.elo_margin, a.rank_spread, a.last_benchmark, a.security_status, a.security_confidence, a.security_last_scan`;
    let rows;
    try {
      const ftsQuery = q.split(/\s+/).map(w => `"${w}"*`).join(' ');
      rows = d.prepare(`SELECT s.*, ${arenaFields} FROM skills_fts fts JOIN skills s ON s.id = fts.rowid ${arenaJoin} WHERE skills_fts MATCH ? LIMIT 500`).all(ftsQuery);
    } catch {
      const like = `%${q}%`;
      rows = d.prepare(`SELECT s.*, ${arenaFields} FROM skills s ${arenaJoin} WHERE s.skill_name LIKE ? OR s.description LIKE ? OR s.domain LIKE ? OR s.keywords LIKE ? OR s.tags LIKE ? LIMIT 500`).all(like, like, like, like, like);
    }
    skills = rows.map(searchRowToSkill).map(enrichArenaFields);
  } else {
    // 浏览模式
    const hasFilters = role || scene || platform || layer;
    skills = hasFilters
      ? skillDb.listSkills().map(enrichArenaFields)
      : skillDb.listSkills({ limit: 500, offset: 0, orderBy: 'download_count', order: 'DESC' }).map(enrichArenaFields);
  }

  skills = computeRanks(skills);

  // 筛选
  if (role) skills = skills.filter(s => (s.roles || []).includes(role));
  if (scene) skills = skills.filter(s => (s.scenes || [s.scene]).includes(scene));
  if (platform) skills = skills.filter(s => (s.platforms || []).includes(platform));
  if (layer) skills = skills.filter(s => s.layer === parseInt(layer));

  // 排序
  skills.sort((a, b) => {
    switch (sort) {
      case 'successRate': return (b.arena?.scores?.successRate || 0) - (a.arena?.scores?.successRate || 0);
      case 'speed': return (a.arena?.scores?.speed || 999) - (b.arena?.scores?.speed || 999);
      case 'cost': return (a.arena?.scores?.cost || 999) - (b.arena?.scores?.cost || 999);
      case 'downloads': return (b.downloadCount || 0) - (a.downloadCount || 0);
      case 'favorites': return (b.favoriteCount || 0) - (a.favoriteCount || 0);
      default: return (b.arena?.elo || 1200) - (a.arena?.elo || 1200);
    }
  });

  const total = skills.length;
  const results = skills.slice(offset, offset + limit).map((s, idx) => ({
    rank: offset + idx + 1, skillId: s.skillId, skillName: s.skillName,
    description: s.description, layer: s.layer, scene: s.scene,
    roles: s.roles || [], scenes: s.scenes || [], platforms: s.platforms || [],
    version: s.version, author: s.author || 'ArenaSK', license: s.license || 'MIT',
    source: s.source || 'local', tags: s.tags || [],
    elo: s.arena?.elo || 1200, eloMargin: s.arena?.eloMargin || 50,
    benchmarkRuns: s.arena?.benchmarkRuns || 0, scores: s.arena?.scores || {},
    securityStatus: s.arena?.securityScan?.status || 'pending',
    highlighted: s.arena?.highlighted || false, trending: s.arena?.trending || false,
    dependencies: s.dependencies || [], useCount: s.useCount || 0,
    downloadCount: s.downloadCount || 0, favoriteCount: s.favoriteCount || 0,
    lastBenchmark: s.arena?.lastBenchmark,
  }));

  res.json({ skills: results, total, page, pageSize: limit, totalPages: Math.ceil(total / limit),
    filters: { role, scene, platform, layer: layer ? parseInt(layer) : null, sort } });
}));

router.get('/skill/:skillName', h((req, res) => {
  const skills = skillDb.listSkills().map(enrichArenaFields);
  const skill = skills.find(s => s.skillName === req.params.skillName);
  if (!skill) return res.status(404).json({ error: 'Skill not found' });

  const depInfo = (s) => ({ skillName: s.skillName, layer: s.layer, scene: s.scene, elo: s.arena?.elo || 1200 });
  const dependents = skills.filter(s => (s.dependencies || []).some(d =>
    d === skill.skillName || d === `l1-${skill.skillName}` || d === `l2-${skill.skillName}`
  )).map(depInfo);
  const dependsOn = (skill.dependencies || []).map(depName => {
    const dep = skills.find(s => s.skillName === depName || s.skillName === depName.replace(/^l[123]-/, ''));
    return dep ? depInfo(dep) : { skillName: depName, layer: null, scene: null, elo: null };
  });

  res.json({ ...skill, dependents, dependsOn, sceneInfo: SCENES.find(s => s.id === skill.scene) || null });
}));

router.get('/compare/:skillA/:skillB', h((req, res) => {
  const skills = skillDb.listSkills().map(enrichArenaFields);
  const skillA = skills.find(s => s.skillName === req.params.skillA);
  const skillB = skills.find(s => s.skillName === req.params.skillB);
  if (!skillA || !skillB) return res.status(404).json({ error: 'One or both skills not found' });

  const dims = ['successRate', 'accuracy', 'speed', 'cost', 'robustness', 'completeness'];
  const comparison = {};
  for (const dim of dims) {
    const a = skillA.arena?.scores?.[dim], b = skillB.arena?.scores?.[dim];
    comparison[dim] = { a, b, winner: a == null || b == null ? null :
      (dim === 'speed' || dim === 'cost') ? (a < b ? 'a' : a > b ? 'b' : 'tie') : (a > b ? 'a' : a < b ? 'b' : 'tie') };
  }
  res.json({ skillA, skillB, comparison, eloDiff: (skillA.arena?.elo || 1200) - (skillB.arena?.elo || 1200) });
}));

router.post('/submit', h((req, res) => {
  const { skillName, description, sourceUrl, layer, scene, tags, roles, scenes, platforms } = req.body;
  if (!skillName || !description) return res.status(400).json({ error: 'skillName and description are required' });
  if (skillDb.getSkill(skillName)) return res.status(409).json({ error: 'Skill already exists' });

  const autoTags = autoTag({ skillName, description, tags });
  const meta = {
    skillName, description,
    source: sourceUrl ? (sourceUrl.includes('clawhub') ? 'clawhub' : sourceUrl.includes('github') ? 'github' : 'community') : 'community',
    layer: layer || 1, scene: scene || autoTags.scenes[0],
    roles: roles || autoTags.roles, scenes: scenes || autoTags.scenes, platforms: platforms || autoTags.platforms,
    keywords: tags || [], dependencies: [], skillsDir: config.skillsCodeDir,
  };
  const newSkill = skillDb.registerSkill(meta);
  enrichArenaFields(newSkill);
  skillDb.updateSkill(skillName, { status: 'pending', arena: newSkill.arena });
  res.status(201).json({ message: 'Skill submitted. Pending review.', skill: newSkill });
}));

router.post('/enrich', h((req, res) => {
  const skills = skillDb.listSkills();
  let enriched = 0;
  for (const skill of skills) { const had = !!skill.arena; enrichArenaFields(skill); if (!had) enriched++; }
  computeRanks(skills);
  for (const skill of skills) { const { skillId, skillName, ...updates } = skill; skillDb.updateSkill(skillName, updates); }
  res.json({ message: `Enriched ${enriched} skills`, total: skills.length, enriched });
}));

// ═══════════════════════════════════════════
//  管理 API (/api/arena/*)
// ═══════════════════════════════════════════

router.post('/benchmark/:skillName', h((req, res) => {
  benchmarkDB.initDB();
  const skill = skillDb.getSkill(req.params.skillName);
  if (!skill) return res.status(404).json({ error: 'Skill not found' });
  enrichArenaFields(skill);
  const { scores, weightedScore } = benchmarkRunner.benchmarkSkill(skill);
  const { skillId, skillName, ...updates } = skill;
  skillDb.updateSkill(skillName, updates);
  res.json({ message: `Benchmark completed for ${skillName}`, skillName, weightedScore, scores, elo: skill.arena?.elo || 1200, benchmarkRuns: skill.arena?.benchmarkRuns || 0 });
}));

router.post('/benchmark-all', h((req, res) => {
  res.json({ message: 'Benchmark completed for all skills', ...benchmarkRunner.benchmarkAll() });
}));

router.get('/skill/:skillName/history', h((req, res) => {
  benchmarkDB.initDB();
  const history = benchmarkDB.getSkillHistory(req.params.skillName, parseInt(req.query.limit) || 30);
  res.json({ skillName: req.params.skillName, total: history.length, history });
}));

router.get('/skill/:skillName/elo-history', h((req, res) => {
  benchmarkDB.initDB();
  const history = benchmarkDB.getEloHistory(req.params.skillName, parseInt(req.query.limit) || 50);
  res.json({ skillName: req.params.skillName, total: history.length, history });
}));

router.get('/benchmark/status', h((req, res) => {
  benchmarkDB.initDB();
  res.json({ ...benchmarkDB.getBenchmarkStats(), recentComparisons: benchmarkDB.getRecentComparisons(10) });
}));

router.get('/skill/:skillName/versions', h((req, res) => {
  const versions = skillDb.getVersionHistory(req.params.skillName);
  res.json({ skillName: req.params.skillName, total: versions.length, versions });
}));

router.get('/skill/:skillName/downloads', h((req, res) => {
  const downloads = skillDb.getDownloadHistory(req.params.skillName, { limit: parseInt(req.query.limit) || 50 });
  res.json({ skillName: req.params.skillName, total: downloads.length, downloads });
}));

router.post('/skill/:skillName/download', h((req, res) => {
  const { userId, userEmail, version } = req.body;
  skillDb.recordDownload(req.params.skillName, { userId, userEmail, version, ipAddress: req.headers['x-forwarded-for'] || req.socket.remoteAddress });
  res.json({ success: true });
}));

router.get('/db-stats', h((req, res) => res.json(skillDb.getStats())));

// ── 收藏 ──
router.post('/skill/:skillName/favorite', h((req, res) => {
  res.json(skillDb.addFavorite(req.params.skillName, req.body.userId || req.headers['x-user-id'] || 'anonymous'));
}));

router.delete('/skill/:skillName/favorite', h((req, res) => {
  res.json(skillDb.removeFavorite(req.params.skillName, req.body.userId || req.headers['x-user-id'] || 'anonymous'));
}));

router.get('/skill/:skillName/favorite', (req, res) => {
  const userId = req.query.userId || req.headers['x-user-id'] || 'anonymous';
  res.json({ favorited: skillDb.isFavorited(req.params.skillName, String(userId)), count: skillDb.getFavoriteCount(req.params.skillName) });
});

// ── My Skills ──
router.get('/myskills', h((req, res) => {
  const userId = String(req.query.userId || req.headers['x-user-id'] || 'anonymous');
  const limit = parseInt(req.query.limit) || 50;
  const pick = (s, extra) => ({ skillName: s.skillName, description: s.description, source: s.source, version: s.version, downloadCount: s.downloadCount, ...extra });
  res.json({
    favorites: skillDb.getUserFavorites(userId, { limit }).map(s => pick(s, { favoritedAt: s.favoritedAt })),
    downloads: skillDb.getUserDownloads(userId, { limit }).map(s => pick(s, { downloadedAt: s.downloadedAt, downloadVersion: s.downloadVersion })),
    trained: skillDb.getUserTrainedSkills(userId, { limit }).map(s => pick(s, { trainedAt: s.trainedAt, trainScore: s.trainScore })),
    userId,
  });
}));

module.exports = router;