tag-skills.js 6.46 KB
#!/usr/bin/env node

/**
 * tag-skills.js — 批量为 Skill 打三维标签 (Role/Scene/Platform)
 *
 * 用法:
 *   node scripts/tag-skills.js                 # 只对无标签的 Skill 打标
 *   node scripts/tag-skills.js --force         # 强制重新打标(覆盖已有)
 *   node scripts/tag-skills.js --dry-run       # 预览模式,不写入 DB
 *   node scripts/tag-skills.js --skill <name>  # 只对指定 Skill 打标
 */

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

// ─── 配置 ───
const ROOT = path.resolve(__dirname, '..');
const SKILL_TAGGER_PATH = path.join(ROOT, 'skills/skill_tagger/SKILL.md');
const SKILLS_DIR = path.join(ROOT, 'skills');

// 加载项目模块
const skillDb = require(path.join(ROOT, 'server/services/skill-db'));
const taxonomy = require(path.join(ROOT, 'server/config/taxonomy'));

// ─── 参数解析 ───
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const force = args.includes('--force');
const skillIdx = args.indexOf('--skill');
const targetSkill = skillIdx !== -1 ? args[skillIdx + 1] : null;

async function main() {
  console.log('[tagger] 🏷️  Skill Tagger 启动');
  console.log(`[tagger]    Dry run: ${dryRun}`);
  console.log(`[tagger]    Force: ${force}`);
  if (targetSkill) console.log(`[tagger]    Target: ${targetSkill}`);

  // 获取所有 Skill
  let skills = skillDb.listSkills();
  console.log(`[tagger]    总计 ${skills.length} 个 Skills`);

  if (targetSkill) {
    skills = skills.filter(s => s.skillName === targetSkill);
    if (skills.length === 0) {
      console.error(`[tagger] ❌ Skill "${targetSkill}" not found`);
      process.exit(1);
    }
  }

  // 过滤需要打标的 Skill
  if (!force) {
    skills = skills.filter(s => {
      const hasRoles = s.roles && s.roles.length > 0;
      const hasScenes = s.scenes && s.scenes.length > 0;
      const hasPlatforms = s.platforms && s.platforms.length > 0;
      return !(hasRoles && hasScenes && hasPlatforms);
    });
  }

  console.log(`[tagger]    待打标: ${skills.length} 个`);

  if (skills.length === 0) {
    console.log('[tagger] ✅ 所有 Skill 已有标签,无需操作。');
    return;
  }

  const skillTaggerContent = fs.readFileSync(SKILL_TAGGER_PATH, 'utf-8');
  let successCount = 0;
  let failCount = 0;
  let fallbackCount = 0;

  for (const skill of skills) {
    console.log(`\n[tagger] ── ${skill.skillName} ──`);

    // 尝试读取 SKILL.md
    let skillMd = '';
    const skillMdPath = path.join(SKILLS_DIR, skill.skillName, 'SKILL.md');
    if (fs.existsSync(skillMdPath)) {
      skillMd = fs.readFileSync(skillMdPath, 'utf-8').substring(0, 2000);
    }

    // 构建输入
    const input = [
      `## Skill Name`,
      skill.skillName,
      '',
      `## Description`,
      skill.description || '(无)',
      '',
      `## Domain`,
      skill.domain || '(无)',
      '',
      `## Keywords`,
      (skill.keywords || []).join(', ') || '(无)',
      '',
      skillMd ? `## SKILL.md Content\n${skillMd}` : '',
    ].join('\n');

    // 尝试调用 Claude CLI
    let tags = null;
    try {
      tags = await callClaudeForTags(skillTaggerContent, input);
      console.log(`[tagger]    Claude 推荐: roles=${JSON.stringify(tags.roles)}, scenes=${JSON.stringify(tags.scenes)}, platforms=${JSON.stringify(tags.platforms)} (confidence: ${tags.confidence})`);
      successCount++;
    } catch (e) {
      console.log(`[tagger]    ⚠️  Claude 调用失败 (${e.message}),使用关键词回退`);
      tags = taxonomy.autoTag(skill);
      tags.confidence = 0.3;
      tags.reasoning = 'keyword-based fallback';
      console.log(`[tagger]    回退标签: roles=${JSON.stringify(tags.roles)}, scenes=${JSON.stringify(tags.scenes)}, platforms=${JSON.stringify(tags.platforms)}`);
      fallbackCount++;
    }

    // 写入 DB
    if (!dryRun) {
      try {
        skillDb.updateSkill(skill.skillName, {
          roles: tags.roles,
          scenes: tags.scenes,
          platforms: tags.platforms,
        });
        console.log(`[tagger]    ✅ 已写入`);
      } catch (e) {
        console.error(`[tagger]    ❌ 写入失败: ${e.message}`);
        failCount++;
      }
    } else {
      console.log(`[tagger]    [dry-run] 跳过写入`);
    }
  }

  console.log(`\n[tagger] 🏁 完成`);
  console.log(`[tagger]    成功 (Claude): ${successCount}`);
  console.log(`[tagger]    回退 (keyword): ${fallbackCount}`);
  console.log(`[tagger]    失败: ${failCount}`);
}

/**
 * 调用 Claude CLI 进行打标
 */
function callClaudeForTags(skillContent, input) {
  return new Promise((resolve, reject) => {
    const prompt = [
      '请按照以下 Skill 规范为目标 Skill 打标签:',
      '',
      '--- Skill 规范 ---',
      skillContent,
      '',
      '--- 目标 Skill ---',
      input,
      '',
      '请直接输出 JSON,不需要其他说明文字。',
    ].join('\n');

    execFile('claude', ['--print', '-p', prompt], { timeout: 30000, maxBuffer: 512 * 1024 }, (err, stdout) => {
      if (err) return reject(err);

      const text = (stdout || '').trim();

      // 提取 JSON
      const jsonMatch = text.match(/\{[\s\S]*\}/);
      if (!jsonMatch) {
        return reject(new Error('No JSON found in response'));
      }

      try {
        const result = JSON.parse(jsonMatch[0]);

        // 验证字段
        if (!Array.isArray(result.roles) || !Array.isArray(result.scenes) || !Array.isArray(result.platforms)) {
          return reject(new Error('Invalid response structure'));
        }

        // 验证标签在预定义列表内
        const validRoles = taxonomy.ROLES.map(r => r.id);
        const validScenes = taxonomy.SCENES.map(s => s.id);
        const validPlatforms = taxonomy.PLATFORMS.map(p => p.id);

        result.roles = result.roles.filter(r => validRoles.includes(r));
        result.scenes = result.scenes.filter(s => validScenes.includes(s));
        result.platforms = result.platforms.filter(p => validPlatforms.includes(p));

        // 确保至少有一个标签
        if (result.roles.length === 0) result.roles = ['developer'];
        if (result.scenes.length === 0) result.scenes = ['code_develop'];
        if (result.platforms.length === 0) result.platforms = ['general'];

        resolve(result);
      } catch (e) {
        reject(new Error(`JSON parse error: ${e.message}`));
      }
    });
  });
}

main().catch(e => {
  console.error('[tagger] 💥 未预期错误:', e);
  process.exit(1);
});