fix-missing-skills.js 8.21 KB
#!/usr/bin/env node
/**
 * fix-missing-skills.js — 补导 data/skills/ 中存在但 SQLite 中缺失的 skill
 *
 * 逻辑:
 *   1. 从 SQLite 获取所有已有 skill_name 集合
 *   2. 扫描 data/skills/ 目录
 *   3. 找出差集(目录中有,但 SQLite 中没有的)
 *   4. 只导入这些缺失的 skill(从 SKILL.md/details.json 提取信息)
 *
 * 用法:
 *   node scripts/fix-missing-skills.js              # 补导缺失
 *   node scripts/fix-missing-skills.js --dry-run    # 预览,不写入
 */

const fs = require('fs');
const path = require('path');

// 复用 skill-db 的 schema 和数据库连接
const skillDb = require('../server/services/skill-db');

const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');

const SKILLS_DIR = path.resolve(__dirname, '../../data/skills');

function extractDescription(dirPath) {
  // 优先从 SKILL.md 提取
  const skillMd = path.join(dirPath, 'SKILL.md');
  if (fs.existsSync(skillMd)) {
    const content = fs.readFileSync(skillMd, 'utf8');
    // 提取 YAML frontmatter 中的 description
    const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
    if (fmMatch) {
      const descMatch = fmMatch[1].match(/description:\s*(.+)/);
      if (descMatch) return descMatch[1].trim().slice(0, 500);
    }
    // fallback: 取第一行非标题非空文本
    const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
    if (lines.length > 0) return lines[0].trim().slice(0, 500);
  }

  // 从 details.json 提取
  const detailsJson = path.join(dirPath, 'details.json');
  if (fs.existsSync(detailsJson)) {
    try {
      const details = JSON.parse(fs.readFileSync(detailsJson, 'utf8'));
      return (details.skill?.summary || '').slice(0, 500);
    } catch {}
  }

  return '';
}

function main() {
  console.log('╔═══════════════════════════════════════════════╗');
  console.log('║  补导缺失 Skills → SQLite                     ║');
  console.log('╚═══════════════════════════════════════════════╝');
  console.log();

  if (DRY_RUN) console.log('⚠️  DRY RUN 模式 — 不会实际写入数据库\n');

  // 1. 检查目录
  if (!fs.existsSync(SKILLS_DIR)) {
    console.error(`❌ Skills 目录不存在: ${SKILLS_DIR}`);
    process.exit(1);
  }

  // 2. 获取 DB 中已有的 skill_name 集合
  const db = skillDb.getDb();
  const existingNames = new Set(
    db.prepare('SELECT skill_name FROM skills').all().map(r => r.skill_name)
  );
  console.log(`📊 数据库中已有: ${existingNames.size} 个 skill`);

  // 3. 扫描目录
  const allDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
    .filter(d => d.isDirectory() && !d.name.startsWith('.') && d.name !== '_meta')
    .map(d => d.name);
  console.log(`📁 目录中共有: ${allDirs.length} 个子目录`);

  // 4. 找出缺失的
  const missing = allDirs.filter(name => !existingNames.has(name));
  console.log(`🔍 缺失数量: ${missing.length}`);

  if (missing.length === 0) {
    console.log('\n✅ 没有缺失的 skill,无需补导');
    return;
  }

  // 5. 准备 SQL
  const skillCols = [
    'skill_id', 'skill_name', 'directory', 'description', 'domain', 'scope',
    'status', 'version', 'benchmark_version', 'industry_avg_score', 'elite_avg_score',
    'current_score', 'use_count', 'download_count', 'update_count', 'last_used',
    'last_updated', 'created_at', 'keywords', 'type', 'agent_name', 'layer',
    'dependencies', 'scene', 'author', 'license', 'source', 'tags',
    'roles', 'scenes', 'platforms',
  ];
  const placeholders = skillCols.map(() => '?').join(', ');
  const insertSql = `INSERT OR IGNORE INTO skills (${skillCols.join(', ')}) VALUES (${placeholders})`;
  const stmtInsert = db.prepare(insertSql);

  const stmtArena = db.prepare(
    `INSERT OR IGNORE INTO skill_arena (skill_id, elo, elo_margin, security_status) VALUES (?, 1200, 50, 'pending')`
  );

  // 6. 批量导入(事务)
  let added = 0;
  let errors = 0;
  const startTime = Date.now();

  console.log('\n--- 开始逐个导入 ---');

  const batchSize = 500;
  for (let i = 0; i < missing.length; i += batchSize) {
    const batch = missing.slice(i, i + batchSize);

    if (!DRY_RUN) {
      const tx = db.transaction(() => {
        for (const name of batch) {
          try {
            const dir = path.join(SKILLS_DIR, name);
            const desc = extractDescription(dir);

            // 检查是否有 details.json 来判断 source
            const hasDetails = fs.existsSync(path.join(dir, 'details.json'));
            const source = hasDetails ? 'clawhub' : 'local';
            const skillId = hasDetails ? `crawled-${name}` : `skill-local-${name}`;
            const now = new Date().toISOString();

            const row = {
              skill_id: skillId,
              skill_name: name,
              directory: dir,
              description: desc,
              domain: '',
              scope: '',
              status: 'enable',
              version: '1.0.0',
              benchmark_version: '1.0.0',
              industry_avg_score: null,
              elite_avg_score: null,
              current_score: null,
              use_count: 0,
              download_count: 0,
              update_count: 0,
              last_used: null,
              last_updated: now,
              created_at: now,
              keywords: '[]',
              type: 'executor',
              agent_name: null,
              layer: null,
              dependencies: '[]',
              scene: '',
              author: source === 'clawhub' ? 'clawhub' : 'local',
              license: 'MIT',
              source: source,
              tags: '[]',
              roles: '[]',
              scenes: '[]',
              platforms: '[]',
            };

            stmtInsert.run(...skillCols.map(c => row[c]));
            stmtArena.run(skillId);
            added++;
          } catch (e) {
            errors++;
            if (errors <= 10) {
              console.error(`  ❌ ${name}: ${e.message}`);
            }
          }
        }
      });
      tx();
    } else {
      added += batch.length;
    }

    const progress = Math.min(i + batchSize, missing.length);
    const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
    process.stdout.write(`\r  📦 已处理 ${progress}/${missing.length} | 用时 ${elapsed}s`);
  }

  console.log('\n');

  // 7. 结果汇总
  const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
  const totalInDb = DRY_RUN ? '(dry-run)' : db.prepare('SELECT COUNT(*) as c FROM skills').get().c;

  console.log('┌──────────────────────────────────────┐');
  console.log('│         补导结果                      │');
  console.log('├──────────────────────────────────────┤');
  console.log(`│  ✅ 新增导入: ${String(added).padStart(8)}               │`);
  console.log(`│  ❌ 错误:     ${String(errors).padStart(8)}               │`);
  console.log(`│  ⏱️  总耗时:   ${String(totalTime + 's').padStart(8)}               │`);
  console.log(`│  📊 数据库总数: ${String(totalInDb).padStart(7)}              │`);
  console.log('└──────────────────────────────────────┘');

  // 8. 验证 tomato
  if (!DRY_RUN) {
    const t = db.prepare("SELECT skill_name, status, source FROM skills WHERE skill_name = 'tomato_short_story'").get();
    console.log(`\n  tomato_short_story: ${t ? `✅ 已导入 (source=${t.source}, status=${t.status})` : '❌ 未找到'}`);

    // 重建 FTS5 索引
    console.log('\n🔍 重建 FTS5 全文搜索索引...');
    try {
      db.exec("INSERT INTO skills_fts(skills_fts) VALUES('rebuild');");
      const ftsCount = db.prepare('SELECT COUNT(*) as c FROM skills_fts').get().c;
      console.log(`   FTS5 索引: ${ftsCount} 条`);
    } catch (e) {
      console.warn(`   FTS5 重建失败: ${e.message}`);
    }
  }

  console.log('\n✨ 完成!');
}

main();