bookworm-smart-assistant/scripts/skill-retirement-advisor.js

457 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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,
};
}