479 lines
17 KiB
JavaScript
479 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Adaptive Disambiguator — Bayesian Dirichlet 学习型消歧器 (v6.0 F2-1)
|
||
*
|
||
* 设计原理:
|
||
* - 对每对 (skill_a, skill_b) 维护 Dirichlet 分布 α 向量
|
||
* - 初始先验: α=1.0 (弱先验,等概率)
|
||
* - 硬规则先验加权: 硬规则 boost 技能获得 α=10 的强先验
|
||
* - 每次用户确认后更新: α[used_skill] += 1
|
||
* - 消歧融合: Bayesian 后验 × 0.3 + 硬规则 boost/penalty × 0.7
|
||
*
|
||
* 数据持久化: debug/adaptive-disambiguator-state.json
|
||
* Fail-open: 任何异常 → 返回原始 candidates 不修改
|
||
*
|
||
* CLI 用法:
|
||
* node scripts/adaptive-disambiguator.js --state → 输出当前学习状态
|
||
* node scripts/adaptive-disambiguator.js --reset → 重置所有学习数据
|
||
*/
|
||
|
||
'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 STATE_FILE = path.join(DEBUG_DIR, 'adaptive-disambiguator-state.json');
|
||
const FEEDBACK_FILE = path.join(DEBUG_DIR, 'route-feedback.jsonl');
|
||
|
||
// ─── 融合权重配置 ──────────────────────────────────────
|
||
const CONFIG = {
|
||
// 硬规则权重 vs Bayesian 后验权重
|
||
hardRuleWeight: 0.7,
|
||
bayesianWeight: 0.3,
|
||
// 硬规则先验: boost 技能的初始 α (强先验,需大量反面证据才能推翻)
|
||
hardRulePrior: 10,
|
||
// 弱先验: 非 boost 技能的初始 α
|
||
weakPrior: 1.0,
|
||
// 样本充足阈值: 超过此值进入确定性选择
|
||
convergenceThreshold: 30,
|
||
// 收敛置信阈值: 后验概率超过此值视为收敛
|
||
convergenceConfidence: 0.80,
|
||
// 偏离警告阈值: 学习权重偏离先验超过 50% 时记录 evolution-log
|
||
driftWarningRatio: 0.5,
|
||
};
|
||
|
||
// ─── 状态管理 ──────────────────────────────────────────
|
||
|
||
/**
|
||
* 加载持久化状态
|
||
* 结构: {
|
||
* version: string,
|
||
* updatedAt: string,
|
||
* pairs: {
|
||
* "<skillA>|<skillB>": {
|
||
* alphas: { skillName: number, ... },
|
||
* totalSamples: number,
|
||
* lastUpdated: string
|
||
* }
|
||
* }
|
||
* }
|
||
*/
|
||
function loadState() {
|
||
try {
|
||
if (!fs.existsSync(STATE_FILE)) {
|
||
return { version: '1.0', updatedAt: null, pairs: {} };
|
||
}
|
||
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||
} catch {
|
||
return { version: '1.0', updatedAt: null, pairs: {} };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 持久化状态到磁盘
|
||
*/
|
||
function saveState(state) {
|
||
try {
|
||
if (!fs.existsSync(DEBUG_DIR)) {
|
||
fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
||
}
|
||
state.updatedAt = new Date().toISOString();
|
||
// V04 修复: 原子写入 (temp+rename),防止进程崩溃时文件损坏
|
||
const tmpFile = STATE_FILE + '.tmp';
|
||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2) + '\n');
|
||
fs.renameSync(tmpFile, STATE_FILE);
|
||
} catch {
|
||
// 写入失败时静默忽略,不影响主流程
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成 pair key (排序以确保 a|b 与 b|a 是同一个 key)
|
||
*/
|
||
function pairKey(skillA, skillB) {
|
||
return [skillA, skillB].sort().join('|');
|
||
}
|
||
|
||
/**
|
||
* 获取或初始化一个 pair 的 Dirichlet 状态
|
||
* @param {Object} state - 全局状态
|
||
* @param {string} skillA - 技能 A
|
||
* @param {string} skillB - 技能 B
|
||
* @param {string} hardRuleBoost - 硬规则 boost 的技能 (若有)
|
||
*/
|
||
function getOrInitPair(state, skillA, skillB, hardRuleBoost) {
|
||
const key = pairKey(skillA, skillB);
|
||
if (!state.pairs[key]) {
|
||
// 初始化 Dirichlet 先验
|
||
const alphas = {};
|
||
alphas[skillA] = skillA === hardRuleBoost ? CONFIG.hardRulePrior : CONFIG.weakPrior;
|
||
alphas[skillB] = skillB === hardRuleBoost ? CONFIG.hardRulePrior : CONFIG.weakPrior;
|
||
// XC1 修复: 保存初始 alpha 快照,供 _checkDriftWarning 计算 expectedPrior
|
||
const initialAlphas = { ...alphas };
|
||
state.pairs[key] = {
|
||
alphas,
|
||
_initialAlphas: initialAlphas,
|
||
totalSamples: 0,
|
||
lastUpdated: null,
|
||
};
|
||
}
|
||
return state.pairs[key];
|
||
}
|
||
|
||
// ─── Bayesian 后验计算 ─────────────────────────────────
|
||
|
||
/**
|
||
* 计算 skill 在 pair 中的 Bayesian 后验概率
|
||
* 期望值 = α_i / Σ(α)
|
||
*/
|
||
function computePosterior(pair, skillName) {
|
||
const alphas = pair.alphas || {};
|
||
const totalAlpha = Object.values(alphas).reduce((s, a) => s + a, 0);
|
||
if (totalAlpha <= 0) return 0.5; // 均匀分布
|
||
return (alphas[skillName] || CONFIG.weakPrior) / totalAlpha;
|
||
}
|
||
|
||
/**
|
||
* 检查 pair 是否已收敛 (样本充足且后验集中)
|
||
* 收敛时返回 winner,否则返回 null
|
||
*/
|
||
function checkConvergence(pair) {
|
||
if ((pair.totalSamples || 0) < CONFIG.convergenceThreshold) return null;
|
||
const alphas = pair.alphas || {};
|
||
const totalAlpha = Object.values(alphas).reduce((s, a) => s + a, 0);
|
||
if (totalAlpha <= 0) return null;
|
||
|
||
let maxAlpha = 0, winner = null;
|
||
for (const [skill, alpha] of Object.entries(alphas)) {
|
||
if (alpha > maxAlpha) {
|
||
maxAlpha = alpha;
|
||
winner = skill;
|
||
}
|
||
}
|
||
const posterior = totalAlpha > 0 ? maxAlpha / totalAlpha : 0;
|
||
return posterior >= CONFIG.convergenceConfidence ? winner : null;
|
||
}
|
||
|
||
// ─── 核心消歧函数 ──────────────────────────────────────
|
||
|
||
/**
|
||
* 自适应消歧: 融合硬规则结果与 Bayesian 后验概率
|
||
*
|
||
* @param {Array<{name: string, score: number}>} candidates - 当前候选技能列表 (已按 score 排序)
|
||
* @param {Object} context - 查询上下文 { prompt, domain, intent }
|
||
* @param {Object} hardRuleResults - 硬规则 boost/penalty 结果
|
||
* 结构: { boosted: string[], penalized: string[], firedRules: string[] }
|
||
* @returns {Array<{name: string, score: number}>} 融合后重排的候选列表
|
||
*/
|
||
function adaptiveDisambiguate(candidates, context, hardRuleResults) {
|
||
if (!candidates || candidates.length < 2) return candidates || [];
|
||
|
||
try {
|
||
const state = loadState();
|
||
const boosted = (hardRuleResults && hardRuleResults.boosted) ? hardRuleResults.boosted : [];
|
||
const penalized = (hardRuleResults && hardRuleResults.penalized) ? hardRuleResults.penalized : [];
|
||
|
||
// 对 top-5 候选两两计算 Bayesian 调整
|
||
const topCandidates = candidates.slice(0, 5);
|
||
const adjustments = new Map(); // skillName → 累计 Bayesian 调整分
|
||
|
||
for (let i = 0; i < topCandidates.length; i++) {
|
||
for (let j = i + 1; j < topCandidates.length; j++) {
|
||
const skillA = topCandidates[i].name;
|
||
const skillB = topCandidates[j].name;
|
||
|
||
// 确定此 pair 中硬规则 boost 的技能
|
||
const hardBoostForPair = boosted.find(b => b === skillA || b === skillB) || null;
|
||
const pair = getOrInitPair(state, skillA, skillB, hardBoostForPair);
|
||
|
||
// 计算 Bayesian 后验
|
||
const posteriorA = computePosterior(pair, skillA);
|
||
const posteriorB = computePosterior(pair, skillB);
|
||
|
||
// 积累调整量 (相对于均匀分布 0.5 的偏差)
|
||
const adjA = (adjustments.get(skillA) || 0) + (posteriorA - 0.5);
|
||
const adjB = (adjustments.get(skillB) || 0) + (posteriorB - 0.5);
|
||
adjustments.set(skillA, adjA);
|
||
adjustments.set(skillB, adjB);
|
||
}
|
||
}
|
||
|
||
// 归一化 Bayesian 调整量到 [0, 1] 范围
|
||
let maxAdj = 0;
|
||
for (const adj of adjustments.values()) {
|
||
if (Math.abs(adj) > maxAdj) maxAdj = Math.abs(adj);
|
||
}
|
||
|
||
// 融合最终分数: hardRule × 0.7 + Bayesian × 0.3
|
||
const result = candidates.map(c => {
|
||
const adj = adjustments.get(c.name) || 0;
|
||
const normalizedAdj = maxAdj > 0 ? adj / maxAdj : 0;
|
||
// Bayesian 分量: 以原始分数为基准,用后验概率微调
|
||
const bayesianBoost = normalizedAdj * c.score * CONFIG.bayesianWeight;
|
||
return {
|
||
...c,
|
||
score: Math.round((c.score + bayesianBoost) * 100) / 100,
|
||
_bayesianAdj: Math.round(normalizedAdj * 100) / 100,
|
||
};
|
||
});
|
||
|
||
result.sort((a, b) => b.score - a.score);
|
||
|
||
// 状态不需要保存 (只在 updateFromFeedback 时保存)
|
||
return result;
|
||
} catch {
|
||
// Fail-open: 任何异常返回原始候选
|
||
return candidates;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从路由反馈更新 Dirichlet 先验
|
||
* 由 route-auditor 或 implicit-feedback 调用
|
||
*
|
||
* @param {string} routedSkill - 实际路由到的技能
|
||
* @param {string} correctedSkill - 用户纠正为的技能 (若有,否则等于 routedSkill)
|
||
* @param {string[]} competingSkills - 路由时的竞争技能 (candidates 中其他技能)
|
||
*/
|
||
function updateFromFeedback(routedSkill, correctedSkill, competingSkills) {
|
||
if (!routedSkill) return;
|
||
|
||
try {
|
||
const state = loadState();
|
||
// 实际使用的技能 (纠正后的,或原路由)
|
||
const actualSkill = correctedSkill || routedSkill;
|
||
const wasCorrect = !correctedSkill || correctedSkill === routedSkill;
|
||
|
||
// 对 actualSkill 与所有竞争技能的 pair 进行更新
|
||
for (const competing of (competingSkills || [])) {
|
||
if (competing === actualSkill) continue;
|
||
|
||
const key = pairKey(actualSkill, competing);
|
||
// 懒初始化 (无强先验,因为我们不知道硬规则在这个 pair 上的结论)
|
||
if (!state.pairs[key]) {
|
||
state.pairs[key] = {
|
||
alphas: {
|
||
[actualSkill]: CONFIG.weakPrior,
|
||
[competing]: CONFIG.weakPrior,
|
||
},
|
||
totalSamples: 0,
|
||
lastUpdated: null,
|
||
};
|
||
}
|
||
|
||
const pair = state.pairs[key];
|
||
|
||
// P2-14 修复: 标准 Bayesian 更新 — 仅增加正确技能的 alpha,
|
||
// 不减少错误技能的 alpha。Dirichlet 分布会通过总量增加自然稀释错误技能的后验概率。
|
||
// 移除原来的 -0.5 惩罚,避免人为扭曲先验分布。
|
||
pair.alphas[actualSkill] = (pair.alphas[actualSkill] || CONFIG.weakPrior) + 1;
|
||
|
||
pair.totalSamples = (pair.totalSamples || 0) + 1;
|
||
pair.lastUpdated = new Date().toISOString();
|
||
|
||
// 检查是否偏离先验超过阈值 → 写入 evolution-log
|
||
_checkDriftWarning(actualSkill, competing, pair);
|
||
}
|
||
|
||
saveState(state);
|
||
} catch {
|
||
// Fail-open
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查学习权重是否偏离先验超过 50%
|
||
* 偏离时写入 evolution-log 供人工审查
|
||
*/
|
||
function _checkDriftWarning(skillA, skillB, pair) {
|
||
try {
|
||
const alphas = pair.alphas || {};
|
||
const totalAlpha = Object.values(alphas).reduce((s, a) => s + a, 0);
|
||
if (totalAlpha <= 0) return;
|
||
|
||
for (const [skill, alpha] of Object.entries(alphas)) {
|
||
const posterior = alpha / totalAlpha;
|
||
// XC1 修复: 从 _initialAlphas 快照计算各技能的真实期望先验后验
|
||
// 若无快照则回退到 weakPrior / totalInitialAlpha 估算
|
||
let expectedPrior;
|
||
if (pair._initialAlphas) {
|
||
const initialAlpha = pair._initialAlphas[skill] || CONFIG.weakPrior;
|
||
const totalInitialAlpha = Object.values(pair._initialAlphas).reduce((s, a) => s + a, 0);
|
||
expectedPrior = totalInitialAlpha > 0 ? initialAlpha / totalInitialAlpha : 0.5;
|
||
} else {
|
||
// 旧数据无快照: 根据 hardRulePrior 数值猜测
|
||
expectedPrior = alpha >= CONFIG.hardRulePrior
|
||
? CONFIG.hardRulePrior / (CONFIG.hardRulePrior + CONFIG.weakPrior)
|
||
: CONFIG.weakPrior / (CONFIG.hardRulePrior + CONFIG.weakPrior);
|
||
}
|
||
|
||
if (Math.abs(posterior - expectedPrior) > CONFIG.driftWarningRatio) {
|
||
// V20 修复: 去重 — 同一 pair+skill 24h 内只报一次
|
||
const evolutionLog = path.join(DEBUG_DIR, 'evolution-log.jsonl');
|
||
const dedupKey = `${pairKey(skillA, skillB)}:${skill}`;
|
||
let shouldLog = true;
|
||
try {
|
||
if (fs.existsSync(evolutionLog)) {
|
||
const tail = fs.readFileSync(evolutionLog, 'utf8').split('\n').filter(Boolean).slice(-50);
|
||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||
for (const line of tail) {
|
||
try {
|
||
const e = JSON.parse(line);
|
||
if (e.event === 'adaptive-disambiguator-drift' && e.pair === pairKey(skillA, skillB) && e.skill === skill && new Date(e.ts).getTime() > cutoff) {
|
||
shouldLog = false; break;
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
if (shouldLog) {
|
||
const logEntry = {
|
||
ts: new Date().toISOString(),
|
||
event: 'adaptive-disambiguator-drift',
|
||
pair: pairKey(skillA, skillB),
|
||
skill,
|
||
posterior: Math.round(posterior * 100) / 100,
|
||
samples: pair.totalSamples,
|
||
message: `学习权重偏离先验 ${Math.round(Math.abs(posterior - expectedPrior) * 100)}%,建议人工审查`,
|
||
};
|
||
fs.appendFileSync(evolutionLog, JSON.stringify(logEntry) + '\n');
|
||
}
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
/**
|
||
* 从 route-feedback.jsonl 批量更新 Dirichlet 先验
|
||
* 用于首次初始化或定期补录
|
||
*
|
||
* @param {number} maxEntries - 最大处理条数 (默认 1000)
|
||
*/
|
||
function bulkUpdateFromFeedbackFile(maxEntries) {
|
||
maxEntries = maxEntries || 1000;
|
||
try {
|
||
if (!fs.existsSync(FEEDBACK_FILE)) return { processed: 0, errors: 0 };
|
||
|
||
const lines = fs.readFileSync(FEEDBACK_FILE, 'utf8')
|
||
.split('\n')
|
||
.filter(l => l.trim());
|
||
|
||
let processed = 0, errors = 0;
|
||
|
||
// 取最新的 maxEntries 条
|
||
const entries = lines.slice(-maxEntries);
|
||
|
||
for (const line of entries) {
|
||
try {
|
||
const entry = JSON.parse(line);
|
||
// route-feedback.jsonl 格式: { routedTo, correctedTo, candidates... }
|
||
const routedSkill = entry.routedTo;
|
||
const correctedSkill = entry.correctedTo !== entry.routedTo ? entry.correctedTo : null;
|
||
// candidates 字段不一定存在,用空数组降级
|
||
const competing = (entry.candidates || []).filter(c => c !== routedSkill && c !== correctedSkill);
|
||
updateFromFeedback(routedSkill, correctedSkill, competing);
|
||
processed++;
|
||
} catch {
|
||
errors++;
|
||
}
|
||
}
|
||
|
||
return { processed, errors };
|
||
} catch {
|
||
return { processed: 0, errors: 0 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前学习状态的摘要
|
||
*/
|
||
function getState() {
|
||
try {
|
||
const state = loadState();
|
||
const pairs = state.pairs || {};
|
||
const pairCount = Object.keys(pairs).length;
|
||
|
||
// 统计收敛的 pair
|
||
let convergedCount = 0;
|
||
const convergedPairs = [];
|
||
for (const [key, pair] of Object.entries(pairs)) {
|
||
const winner = checkConvergence(pair);
|
||
if (winner) {
|
||
convergedCount++;
|
||
convergedPairs.push({ pair: key, winner, samples: pair.totalSamples });
|
||
}
|
||
}
|
||
|
||
// 总样本数
|
||
const totalSamples = Object.values(pairs).reduce((s, p) => s + (p.totalSamples || 0), 0);
|
||
|
||
return {
|
||
version: state.version || '1.0',
|
||
updatedAt: state.updatedAt,
|
||
pairCount,
|
||
totalSamples,
|
||
convergedCount,
|
||
convergedPairs: convergedPairs.slice(0, 10), // 只返回前 10 个
|
||
config: CONFIG,
|
||
};
|
||
} catch {
|
||
return { error: 'state unavailable', config: CONFIG };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重置所有学习状态
|
||
*/
|
||
function resetState() {
|
||
try {
|
||
const empty = { version: '1.0', updatedAt: new Date().toISOString(), pairs: {}, resetAt: new Date().toISOString() };
|
||
saveState(empty);
|
||
return { success: true, message: '学习状态已重置' };
|
||
} catch (e) {
|
||
return { success: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
// ─── CLI 入口 ──────────────────────────────────────────
|
||
if (require.main === module) {
|
||
const args = process.argv.slice(2);
|
||
|
||
if (args.includes('--reset')) {
|
||
const result = resetState();
|
||
console.log(result.success ? '[adaptive-disambiguator] 状态已重置' : `[ERROR] ${result.error}`);
|
||
process.exit(result.success ? 0 : 1);
|
||
}
|
||
|
||
if (args.includes('--bulk-update')) {
|
||
const result = bulkUpdateFromFeedbackFile(1000);
|
||
console.log(`[adaptive-disambiguator] 批量更新完成: 处理 ${result.processed} 条, 错误 ${result.errors} 条`);
|
||
process.exit(0);
|
||
}
|
||
|
||
// 默认: 输出当前状态
|
||
const state = getState();
|
||
console.log(JSON.stringify(state, null, 2));
|
||
process.exit(0);
|
||
}
|
||
|
||
// ─── 模块导出 ──────────────────────────────────────────
|
||
if (typeof module !== 'undefined') {
|
||
module.exports = {
|
||
adaptiveDisambiguate,
|
||
updateFromFeedback,
|
||
bulkUpdateFromFeedbackFile,
|
||
getState,
|
||
resetState,
|
||
loadState,
|
||
saveState,
|
||
computePosterior,
|
||
checkConvergence,
|
||
CONFIG,
|
||
};
|
||
}
|