457 lines
15 KiB
JavaScript
457 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Skill Retirement Advisor — 技能退役顾问 (v6.0 F2-3)
|
||
*
|
||
* 功能:
|
||
* 1. 读取 debug/route-stats.json 获取每个技能的命中次数
|
||
* 2. 扫描 debug/route-YYYY-MM-DD.jsonl 获取每个技能最后命中时间
|
||
* 3. 标记连续 30 天零命中的技能为 retired-candidate
|
||
* 4. 输出人类可读报告到 stdout
|
||
* 5. --apply: 在 skills-index.json 中对 retired 技能降低关键词权重 (×0.3)
|
||
* 6. --restore <skill-name>: 恢复指定技能的关键词权重
|
||
*
|
||
* CLI 用法:
|
||
* node scripts/skill-retirement-advisor.js → 输出报告
|
||
* node scripts/skill-retirement-advisor.js --apply → 应用降权
|
||
* node scripts/skill-retirement-advisor.js --restore devops → 恢复权重
|
||
* node scripts/skill-retirement-advisor.js --json → JSON 格式报告
|
||
*
|
||
* 安全约束:
|
||
* - 不删除任何技能文件
|
||
* - 不修改 .claude/hooks/ 目录
|
||
* - 降权因子 × 0.3,原始权重保存在 _originalWeight 字段
|
||
* - 恢复时从 _originalWeight 还原
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ─── 路径检测 ──────────────────────────────────────────
|
||
const detectClaudeRoot = () => require('./paths.config.js').PATHS.root;
|
||
|
||
const CLAUDE_ROOT = detectClaudeRoot();
|
||
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
|
||
const ROUTE_STATS_FILE = path.join(DEBUG_DIR, 'route-stats.json');
|
||
const SKILLS_INDEX_FILE = path.join(CLAUDE_ROOT, 'skills-index.json');
|
||
|
||
// 退役阈值: 连续 N 天零命中
|
||
const RETIREMENT_DAYS = 30;
|
||
// 降权因子: retired 技能的关键词权重乘以此值
|
||
const RETIREMENT_WEIGHT_FACTOR = 0.3;
|
||
// 保留字段名 (原始权重)
|
||
const ORIGINAL_WEIGHT_FIELD = '_originalWeight';
|
||
|
||
// ─── 数据加载 ──────────────────────────────────────────
|
||
|
||
/**
|
||
* 从 route-stats.json 加载技能命中次数统计
|
||
* @returns {Map<string, number>} skillName → totalHits
|
||
*/
|
||
function loadRouteStats() {
|
||
try {
|
||
if (!fs.existsSync(ROUTE_STATS_FILE)) return new Map();
|
||
const data = JSON.parse(fs.readFileSync(ROUTE_STATS_FILE, 'utf8'));
|
||
const stats = data.stats || {};
|
||
return new Map(Object.entries(stats));
|
||
} catch {
|
||
return new Map();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从每日路由日志中提取每个技能最后一次命中的时间
|
||
* 扫描最近 RETIREMENT_DAYS 天的日志文件
|
||
* @returns {Map<string, Date>} skillName → lastSeenDate
|
||
*/
|
||
function loadLastSeenDates() {
|
||
const lastSeen = new Map();
|
||
|
||
try {
|
||
const now = new Date();
|
||
// 扫描 RETIREMENT_DAYS + 5 天的日志 (多扫几天确保覆盖)
|
||
for (let i = 0; i <= RETIREMENT_DAYS + 5; i++) {
|
||
const d = new Date(now.getTime() - i * 86400000);
|
||
const dateStr = d.toISOString().slice(0, 10);
|
||
const logFile = path.join(DEBUG_DIR, `route-${dateStr}.jsonl`);
|
||
|
||
if (!fs.existsSync(logFile)) continue;
|
||
|
||
const lines = fs.readFileSync(logFile, 'utf8').split('\n').filter(l => l.trim());
|
||
for (const line of lines) {
|
||
try {
|
||
const entry = JSON.parse(line);
|
||
const skill = entry.topResult || entry.selectedSkill;
|
||
if (!skill || skill === 'none') continue;
|
||
|
||
const ts = entry.ts ? new Date(entry.ts) : d;
|
||
// 更新: 只保留最新的命中时间
|
||
if (!lastSeen.has(skill) || ts > lastSeen.get(skill)) {
|
||
lastSeen.set(skill, ts);
|
||
}
|
||
|
||
// 也处理 candidates 数组 (次优候选也算"被考虑")
|
||
for (const candidate of (entry.candidates || [])) {
|
||
const cName = typeof candidate === 'string' ? candidate : candidate.name;
|
||
if (cName && cName !== 'none') {
|
||
if (!lastSeen.has(cName) || ts > lastSeen.get(cName)) {
|
||
lastSeen.set(cName, ts);
|
||
}
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
|
||
return lastSeen;
|
||
}
|
||
|
||
/**
|
||
* 从 skills-index.json 加载所有技能名称
|
||
*/
|
||
function loadAllSkillNames() {
|
||
try {
|
||
if (!fs.existsSync(SKILLS_INDEX_FILE)) return [];
|
||
const index = JSON.parse(fs.readFileSync(SKILLS_INDEX_FILE, 'utf8'));
|
||
return (index.skills || []).map(s => s.name);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ─── 退役分析 ──────────────────────────────────────────
|
||
|
||
/**
|
||
* 分析每个技能的退役状态
|
||
* @returns {Array<SkillStatus>} 技能状态列表
|
||
*
|
||
* SkillStatus {
|
||
* name: string,
|
||
* totalHits: number,
|
||
* lastSeenDate: Date|null,
|
||
* daysSinceLastSeen: number|null, // null = 从未被命中
|
||
* status: 'active' | 'idle' | 'retired-candidate',
|
||
* reason: string
|
||
* }
|
||
*/
|
||
function analyzeRetirement() {
|
||
const routeStats = loadRouteStats();
|
||
const lastSeenDates = loadLastSeenDates();
|
||
const allSkills = loadAllSkillNames();
|
||
|
||
if (allSkills.length === 0) {
|
||
return { error: 'skills-index.json 不存在或为空', skills: [] };
|
||
}
|
||
|
||
const now = new Date();
|
||
const results = [];
|
||
|
||
for (const skillName of allSkills) {
|
||
const totalHits = routeStats.get(skillName) || 0;
|
||
const lastSeenDate = lastSeenDates.get(skillName) || null;
|
||
const daysSinceLastSeen = lastSeenDate
|
||
? Math.floor((now - lastSeenDate) / 86400000)
|
||
: null;
|
||
|
||
let status, reason;
|
||
|
||
if (totalHits === 0 && lastSeenDate === null) {
|
||
// 从未被命中
|
||
status = 'retired-candidate';
|
||
reason = '从未被路由命中 (零历史记录)';
|
||
} else if (daysSinceLastSeen !== null && daysSinceLastSeen >= RETIREMENT_DAYS) {
|
||
// 超过 30 天没有命中
|
||
status = 'retired-candidate';
|
||
reason = `连续 ${daysSinceLastSeen} 天零命中 (最后命中: ${lastSeenDate.toISOString().slice(0, 10)})`;
|
||
} else if (daysSinceLastSeen !== null && daysSinceLastSeen >= 14) {
|
||
// 14-29 天: 空闲观察期
|
||
status = 'idle';
|
||
reason = `${daysSinceLastSeen} 天未命中 (观察期,未达退役阈值 ${RETIREMENT_DAYS} 天)`;
|
||
} else {
|
||
status = 'active';
|
||
reason = lastSeenDate
|
||
? `${daysSinceLastSeen} 天前命中,共 ${totalHits} 次`
|
||
: `命中 ${totalHits} 次`;
|
||
}
|
||
|
||
results.push({
|
||
name: skillName,
|
||
totalHits,
|
||
lastSeenDate,
|
||
daysSinceLastSeen,
|
||
status,
|
||
reason,
|
||
});
|
||
}
|
||
|
||
// 按状态+最后命中排序
|
||
results.sort((a, b) => {
|
||
const order = { 'retired-candidate': 0, 'idle': 1, 'active': 2 };
|
||
const oa = order[a.status] ?? 3;
|
||
const ob = order[b.status] ?? 3;
|
||
if (oa !== ob) return oa - ob;
|
||
// 同状态: 按最后命中时间升序 (越久没用排越前)
|
||
const da = a.lastSeenDate ? a.lastSeenDate.getTime() : 0;
|
||
const db = b.lastSeenDate ? b.lastSeenDate.getTime() : 0;
|
||
return da - db;
|
||
});
|
||
|
||
return { skills: results };
|
||
}
|
||
|
||
// ─── 降权应用 ──────────────────────────────────────────
|
||
|
||
/**
|
||
* 对 retired-candidate 技能在 skills-index.json 中降低关键词权重
|
||
* 原始权重保存在 _originalWeight 字段以便恢复
|
||
* @param {string[]} retiredSkills - 要降权的技能名称列表
|
||
* @returns {{ modified: string[], skipped: string[], errors: string[] }}
|
||
*/
|
||
function applyRetirementPenalty(retiredSkills) {
|
||
const modified = [], skipped = [], errors = [];
|
||
|
||
try {
|
||
if (!fs.existsSync(SKILLS_INDEX_FILE)) {
|
||
return { modified, skipped, errors: ['skills-index.json 不存在'] };
|
||
}
|
||
|
||
const index = JSON.parse(fs.readFileSync(SKILLS_INDEX_FILE, 'utf8'));
|
||
const skills = index.skills || [];
|
||
let changed = false;
|
||
|
||
for (const skill of skills) {
|
||
if (!retiredSkills.includes(skill.name)) continue;
|
||
|
||
// 检查是否已经降权过
|
||
const alreadyRetired = (skill.keywords || []).some(kw => kw[ORIGINAL_WEIGHT_FIELD] !== undefined);
|
||
if (alreadyRetired) {
|
||
skipped.push(`${skill.name} (已降权,跳过)`);
|
||
continue;
|
||
}
|
||
|
||
// 对每个关键词保存原始权重并降权
|
||
let anyModified = false;
|
||
for (const kw of (skill.keywords || [])) {
|
||
const originalWeight = kw.weight;
|
||
if (typeof originalWeight === 'number' && originalWeight > 0) {
|
||
kw[ORIGINAL_WEIGHT_FIELD] = originalWeight;
|
||
kw.weight = Math.round(originalWeight * RETIREMENT_WEIGHT_FACTOR * 1000) / 1000;
|
||
anyModified = true;
|
||
}
|
||
// tfidfWeight 同样降权
|
||
if (typeof kw.tfidfWeight === 'number' && kw.tfidfWeight > 0) {
|
||
kw._originalTfidfWeight = kw.tfidfWeight;
|
||
kw.tfidfWeight = Math.round(kw.tfidfWeight * RETIREMENT_WEIGHT_FACTOR * 1000) / 1000;
|
||
}
|
||
}
|
||
|
||
if (anyModified) {
|
||
// 标记技能为退役候选
|
||
skill._retiredAt = new Date().toISOString();
|
||
skill._retirementReason = `${RETIREMENT_DAYS}天零命中自动降权`;
|
||
modified.push(skill.name);
|
||
changed = true;
|
||
} else {
|
||
skipped.push(`${skill.name} (无关键词可降权)`);
|
||
}
|
||
}
|
||
|
||
if (changed) {
|
||
fs.writeFileSync(SKILLS_INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
|
||
}
|
||
} catch (e) {
|
||
errors.push(e.message);
|
||
}
|
||
|
||
return { modified, skipped, errors };
|
||
}
|
||
|
||
/**
|
||
* 恢复指定技能的关键词权重
|
||
* @param {string} skillName - 要恢复的技能名称
|
||
* @returns {{ success: boolean, message: string }}
|
||
*/
|
||
function restoreSkillWeight(skillName) {
|
||
try {
|
||
if (!fs.existsSync(SKILLS_INDEX_FILE)) {
|
||
return { success: false, message: 'skills-index.json 不存在' };
|
||
}
|
||
|
||
const index = JSON.parse(fs.readFileSync(SKILLS_INDEX_FILE, 'utf8'));
|
||
const skill = (index.skills || []).find(s => s.name === skillName);
|
||
|
||
if (!skill) {
|
||
return { success: false, message: `技能 ${skillName} 不存在于索引中` };
|
||
}
|
||
|
||
let restoredCount = 0;
|
||
for (const kw of (skill.keywords || [])) {
|
||
if (kw[ORIGINAL_WEIGHT_FIELD] !== undefined) {
|
||
kw.weight = kw[ORIGINAL_WEIGHT_FIELD];
|
||
delete kw[ORIGINAL_WEIGHT_FIELD];
|
||
restoredCount++;
|
||
}
|
||
if (kw._originalTfidfWeight !== undefined) {
|
||
kw.tfidfWeight = kw._originalTfidfWeight;
|
||
delete kw._originalTfidfWeight;
|
||
}
|
||
}
|
||
|
||
if (restoredCount === 0) {
|
||
return { success: false, message: `${skillName} 没有被降权的关键词,无需恢复` };
|
||
}
|
||
|
||
// 清除退役标记
|
||
delete skill._retiredAt;
|
||
delete skill._retirementReason;
|
||
skill._restoredAt = new Date().toISOString();
|
||
|
||
fs.writeFileSync(SKILLS_INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
|
||
return { success: true, message: `${skillName} 已恢复 ${restoredCount} 个关键词权重` };
|
||
} catch (e) {
|
||
return { success: false, message: e.message };
|
||
}
|
||
}
|
||
|
||
// ─── 报告生成 ──────────────────────────────────────────
|
||
|
||
function printReport(analysis) {
|
||
const { skills, error } = analysis;
|
||
|
||
if (error) {
|
||
console.error(`[ERROR] ${error}`);
|
||
return;
|
||
}
|
||
|
||
const retired = skills.filter(s => s.status === 'retired-candidate');
|
||
const idle = skills.filter(s => s.status === 'idle');
|
||
const active = skills.filter(s => s.status === 'active');
|
||
|
||
console.log('\n=== Skill Retirement Advisor Report ===');
|
||
console.log(`生成时间: ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`);
|
||
console.log(`技能总数: ${skills.length} | 活跃: ${active.length} | 空闲: ${idle.length} | 退役候选: ${retired.length}`);
|
||
console.log(`退役阈值: 连续 ${RETIREMENT_DAYS} 天零命中`);
|
||
console.log(`降权因子: ×${RETIREMENT_WEIGHT_FACTOR}\n`);
|
||
|
||
if (retired.length > 0) {
|
||
console.log('--- 退役候选 (RETIRED-CANDIDATE) ---');
|
||
for (const s of retired) {
|
||
const hitsStr = s.totalHits > 0 ? ` [历史 ${s.totalHits} 次]` : ' [从未命中]';
|
||
console.log(` ✗ ${s.name.padEnd(35)} ${hitsStr}`);
|
||
console.log(` 原因: ${s.reason}`);
|
||
}
|
||
console.log();
|
||
}
|
||
|
||
if (idle.length > 0) {
|
||
console.log('--- 空闲观察 (IDLE, 14-29天) ---');
|
||
for (const s of idle) {
|
||
console.log(` ~ ${s.name.padEnd(35)} [${s.totalHits} 次] ${s.reason}`);
|
||
}
|
||
console.log();
|
||
}
|
||
|
||
if (active.length > 0) {
|
||
console.log('--- 活跃技能 (ACTIVE) ---');
|
||
for (const s of active) {
|
||
const lastStr = s.lastSeenDate ? s.lastSeenDate.toISOString().slice(0, 10) : '-';
|
||
console.log(` ✓ ${s.name.padEnd(35)} [${s.totalHits} 次] 最后: ${lastStr}`);
|
||
}
|
||
console.log();
|
||
}
|
||
|
||
if (retired.length > 0) {
|
||
console.log('--- 操作建议 ---');
|
||
console.log(` 运行以下命令对退役候选技能降权:`);
|
||
console.log(` node scripts/skill-retirement-advisor.js --apply\n`);
|
||
console.log(` 恢复指定技能权重:`);
|
||
console.log(` node scripts/skill-retirement-advisor.js --restore <skill-name>\n`);
|
||
console.log(` 注意: 降权不删除技能文件,仅降低路由优先级 (×${RETIREMENT_WEIGHT_FACTOR})`);
|
||
} else {
|
||
console.log(' 所有技能均处于活跃或观察状态,无需退役操作。');
|
||
}
|
||
|
||
console.log('\n=======================================\n');
|
||
}
|
||
|
||
// ─── CLI 入口 ──────────────────────────────────────────
|
||
if (require.main === module) {
|
||
const args = process.argv.slice(2);
|
||
const jsonMode = args.includes('--json');
|
||
const applyMode = args.includes('--apply');
|
||
const restoreIdx = args.indexOf('--restore');
|
||
const restoreSkill = restoreIdx >= 0 ? args[restoreIdx + 1] : null;
|
||
|
||
if (restoreSkill) {
|
||
// 恢复模式
|
||
const result = restoreSkillWeight(restoreSkill);
|
||
if (jsonMode) {
|
||
console.log(JSON.stringify(result, null, 2));
|
||
} else {
|
||
console.log(result.success
|
||
? `[OK] ${result.message}`
|
||
: `[ERROR] ${result.message}`);
|
||
}
|
||
process.exit(result.success ? 0 : 1);
|
||
}
|
||
|
||
// 分析退役状态
|
||
const analysis = analyzeRetirement();
|
||
|
||
if (applyMode) {
|
||
// 应用降权
|
||
const retiredNames = (analysis.skills || [])
|
||
.filter(s => s.status === 'retired-candidate')
|
||
.map(s => s.name);
|
||
|
||
if (retiredNames.length === 0) {
|
||
console.log('[OK] 无退役候选技能,无需降权');
|
||
process.exit(0);
|
||
}
|
||
|
||
const applyResult = applyRetirementPenalty(retiredNames);
|
||
|
||
if (jsonMode) {
|
||
console.log(JSON.stringify({ analysis, applyResult }, null, 2));
|
||
} else {
|
||
printReport(analysis);
|
||
console.log('--- 降权执行结果 ---');
|
||
if (applyResult.modified.length > 0) {
|
||
console.log(` 已降权: ${applyResult.modified.join(', ')}`);
|
||
}
|
||
if (applyResult.skipped.length > 0) {
|
||
console.log(` 已跳过: ${applyResult.skipped.join(', ')}`);
|
||
}
|
||
if (applyResult.errors.length > 0) {
|
||
console.error(` 错误: ${applyResult.errors.join(', ')}`);
|
||
}
|
||
}
|
||
process.exit(applyResult.errors.length > 0 ? 1 : 0);
|
||
}
|
||
|
||
// 仅报告模式
|
||
if (jsonMode) {
|
||
console.log(JSON.stringify(analysis, null, 2));
|
||
} else {
|
||
printReport(analysis);
|
||
}
|
||
|
||
process.exit(0);
|
||
}
|
||
|
||
// ─── 模块导出 ──────────────────────────────────────────
|
||
if (typeof module !== 'undefined') {
|
||
module.exports = {
|
||
analyzeRetirement,
|
||
applyRetirementPenalty,
|
||
restoreSkillWeight,
|
||
loadRouteStats,
|
||
loadLastSeenDates,
|
||
loadAllSkillNames,
|
||
RETIREMENT_DAYS,
|
||
RETIREMENT_WEIGHT_FACTOR,
|
||
};
|
||
}
|