721 lines
26 KiB
JavaScript
721 lines
26 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* 融合权重自适应学习器 (v5.9)
|
|||
|
|
*
|
|||
|
|
* 对路由融合权重 (BM25, Semantic, Context, Project, Workflow) 进行在线学习。
|
|||
|
|
* 基于反馈纠正数据,用 projected gradient descent 调整权重使路由更准确。
|
|||
|
|
*
|
|||
|
|
* 约束:
|
|||
|
|
* - 权重之和 = 1.0 (概率单纯形)
|
|||
|
|
* - 每个权重 ∈ [0.05, 0.6] (防止退化)
|
|||
|
|
* - 学习率 η = 0.02 (保守更新)
|
|||
|
|
*
|
|||
|
|
* 用法:
|
|||
|
|
* node fusion-weight-learner.js # 学习并输出权重
|
|||
|
|
* node fusion-weight-learner.js --dry-run # 仅预览
|
|||
|
|
* node fusion-weight-learner.js --reset # 重置为默认权重
|
|||
|
|
* node fusion-weight-learner.js --json # JSON 输出
|
|||
|
|
*
|
|||
|
|
* 文件:
|
|||
|
|
* debug/fusion-weights.json 学习后的融合权重
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
// MEDIUM-1: 引入 WeightStore,使用 safeWriteJson 进行并发安全写入
|
|||
|
|
// 若加载失败则回退到直接写入 (不阻断功能)
|
|||
|
|
let _weightStore = null;
|
|||
|
|
try {
|
|||
|
|
const _ws = require('./weight-store.js');
|
|||
|
|
if (typeof _ws.safeWriteJson === 'function') {
|
|||
|
|
_weightStore = _ws;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
const detectClaudeRoot = () => require('./paths.config.js').PATHS.root;
|
|||
|
|
|
|||
|
|
const ROOT = detectClaudeRoot();
|
|||
|
|
const DEBUG_DIR = path.join(ROOT, 'debug');
|
|||
|
|
const FEEDBACK_FILE = path.join(DEBUG_DIR, 'route-feedback.jsonl');
|
|||
|
|
const FUSION_WEIGHTS_FILE = path.join(DEBUG_DIR, 'fusion-weights.json');
|
|||
|
|
const INDEX_FILE = path.join(ROOT, 'skills-index.json');
|
|||
|
|
|
|||
|
|
// 默认融合权重 (与 route-interceptor.js 一致)
|
|||
|
|
const DEFAULT_WEIGHTS = {
|
|||
|
|
bm25: 0.40,
|
|||
|
|
semantic: 0.30,
|
|||
|
|
context: 0.15,
|
|||
|
|
project: 0.10,
|
|||
|
|
workflow: 0.05,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 约束
|
|||
|
|
const MIN_WEIGHT = 0.05;
|
|||
|
|
const MAX_WEIGHT = 0.60;
|
|||
|
|
const LEARNING_RATE = 0.02;
|
|||
|
|
// W1 修复 (patch-w1-weight-decay, 2026-04-16): L2 正则化强度
|
|||
|
|
// 每批次将权重按比例拉回 DEFAULT_WEIGHTS,防止 simulateSignals 三维=0 导致的单向漂移
|
|||
|
|
const WEIGHT_DECAY = 0.02;
|
|||
|
|
const DECAY_HALF_LIFE = 7 * 86400000; // 7 天半衰期
|
|||
|
|
|
|||
|
|
// v5.9: 多项目隔离支持
|
|||
|
|
let _isolator = null;
|
|||
|
|
try { _isolator = require('./project-isolator.js'); } catch {}
|
|||
|
|
function getWeightsFile(cwd) {
|
|||
|
|
if (_isolator && cwd) return _isolator.getIsolatedPath('fusion-weights.json', cwd);
|
|||
|
|
return FUSION_WEIGHTS_FILE;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 安全写入 fusion-weights.json
|
|||
|
|
* BUG-FIX: 原来委托给 weight-store.safeWriteJson(async Promise),
|
|||
|
|
* 但没有 await,导致进程退出时写入从未完成,权重永远停在 bootstrap 值。
|
|||
|
|
* fusion-weights.json 只有一个写入者(本文件),无并发写入,不需要锁。
|
|||
|
|
* 直接使用同步 fs.writeFileSync 保证写入在进程退出前完成。
|
|||
|
|
* @param {string} weightsFile - 目标文件路径
|
|||
|
|
* @param {Object} data - 要写入的数据
|
|||
|
|
*/
|
|||
|
|
function safeWriteFusionWeights(weightsFile, data) {
|
|||
|
|
_directWriteFallback(weightsFile, data);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 直接写入回退(weight-store 不可用时) */
|
|||
|
|
function _directWriteFallback(weightsFile, data) {
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(path.dirname(weightsFile))) {
|
|||
|
|
fs.mkdirSync(path.dirname(weightsFile), { recursive: true });
|
|||
|
|
}
|
|||
|
|
fs.writeFileSync(weightsFile, JSON.stringify(data, null, 2) + '\n');
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* C1 原子重置助手 (patch-c1-atomic-reset, 2026-04-16)
|
|||
|
|
* 用 tmp+rename 覆盖为 DEFAULT_WEIGHTS,避免 unlinkSync 与并发 readFileSync 的 TOCTOU 竞态。
|
|||
|
|
* 读者永远看到合法 JSON (旧值或新的 DEFAULT),不会 ENOENT。
|
|||
|
|
*/
|
|||
|
|
function atomicResetWeightsFile(weightsFile, reason) {
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(path.dirname(weightsFile))) {
|
|||
|
|
fs.mkdirSync(path.dirname(weightsFile), { recursive: true });
|
|||
|
|
}
|
|||
|
|
const payload = {
|
|||
|
|
weights: { ...DEFAULT_WEIGHTS },
|
|||
|
|
resetAt: new Date().toISOString(),
|
|||
|
|
resetReason: reason || 'manual',
|
|||
|
|
};
|
|||
|
|
const _tmp = weightsFile + '.tmp.' + process.pid;
|
|||
|
|
fs.writeFileSync(_tmp, JSON.stringify(payload, null, 2) + '\n');
|
|||
|
|
fs.renameSync(_tmp, weightsFile);
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 加载当前融合权重
|
|||
|
|
* @param {string} [cwd] - 工作目录 (v5.9 per-cwd 隔离)
|
|||
|
|
* @returns {Object} { bm25, semantic, context, project, workflow }
|
|||
|
|
*/
|
|||
|
|
function loadWeights(cwd) {
|
|||
|
|
try {
|
|||
|
|
const file = getWeightsFile(cwd);
|
|||
|
|
if (fs.existsSync(file)) {
|
|||
|
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|||
|
|
if (data.weights) return { ...DEFAULT_WEIGHTS, ...data.weights };
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
return { ...DEFAULT_WEIGHTS };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 投影到概率单纯形 (权重之和=1, 每个 ∈ [MIN, MAX])
|
|||
|
|
* V01 修复: 迭代 clamp+归一化,确保投影后所有约束同时满足
|
|||
|
|
*/
|
|||
|
|
/**
|
|||
|
|
* 标准约束单纯形投影 (Duchi et al. 2008 变体)
|
|||
|
|
* 保证: sum(w) = 1.0 且 w[k] ∈ [MIN_WEIGHT, MAX_WEIGHT]
|
|||
|
|
* 算法: 排序 → 阈值查找 → 投影 → box clamp → 精确余数分配
|
|||
|
|
*/
|
|||
|
|
function projectToSimplex(w) {
|
|||
|
|
const keys = Object.keys(w).sort(); // 确定性排序
|
|||
|
|
const n = keys.length;
|
|||
|
|
|
|||
|
|
// Step 1: 不带 box 约束的单纯形投影 (Duchi 2008)
|
|||
|
|
const vals = keys.map(k => w[k]);
|
|||
|
|
vals.sort((a, b) => b - a); // 降序
|
|||
|
|
let cumSum = 0;
|
|||
|
|
let rho = 0;
|
|||
|
|
for (let j = 0; j < n; j++) {
|
|||
|
|
cumSum += vals[j];
|
|||
|
|
if (vals[j] - (cumSum - 1) / (j + 1) > 0) rho = j + 1;
|
|||
|
|
}
|
|||
|
|
const theta = (keys.reduce((s, k) => s + w[k], 0) - 1) / rho;
|
|||
|
|
for (const k of keys) {
|
|||
|
|
w[k] = Math.max(0, w[k] - theta);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Step 2: Box 约束 clamp [MIN_WEIGHT, MAX_WEIGHT]
|
|||
|
|
for (const k of keys) {
|
|||
|
|
w[k] = Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w[k]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Step 3: 精确余数分配到离边界最远的维度
|
|||
|
|
for (const k of keys) {
|
|||
|
|
w[k] = Math.round(w[k] * 1000) / 1000;
|
|||
|
|
}
|
|||
|
|
let residual = 1.0 - keys.reduce((s, k) => s + w[k], 0);
|
|||
|
|
residual = Math.round(residual * 1000) / 1000;
|
|||
|
|
if (Math.abs(residual) > 0) {
|
|||
|
|
// 找离边界最远的维度来吸收残差
|
|||
|
|
let bestKey = keys[0];
|
|||
|
|
let bestRoom = 0;
|
|||
|
|
for (const k of keys) {
|
|||
|
|
const room = residual > 0
|
|||
|
|
? MAX_WEIGHT - w[k] // 需要增加,找上界余量最大的
|
|||
|
|
: w[k] - MIN_WEIGHT; // 需要减少,找下界余量最大的
|
|||
|
|
if (room > bestRoom) { bestRoom = room; bestKey = k; }
|
|||
|
|
}
|
|||
|
|
w[bestKey] = Math.round((w[bestKey] + residual) * 1000) / 1000;
|
|||
|
|
w[bestKey] = Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w[bestKey]));
|
|||
|
|
}
|
|||
|
|
return w;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 加载反馈数据中的纠正条目
|
|||
|
|
*/
|
|||
|
|
function loadCorrections() {
|
|||
|
|
if (!fs.existsSync(FEEDBACK_FILE)) return [];
|
|||
|
|
return fs.readFileSync(FEEDBACK_FILE, 'utf8')
|
|||
|
|
.split('\n').filter(Boolean)
|
|||
|
|
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|||
|
|
.filter(f => f && f.routedTo && f.correctedTo && f.routedTo !== f.correctedTo && f.routedTo !== 'unknown');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 为每条纠正模拟各信号分数
|
|||
|
|
* 因为历史纠正没有保存各信号分数,使用当前引擎重新计算
|
|||
|
|
*/
|
|||
|
|
function simulateSignals(query, skillName, index, analyzer, semanticScorer) {
|
|||
|
|
const signals = { bm25: 0, semantic: 0, context: 0, project: 0, workflow: 0 };
|
|||
|
|
|
|||
|
|
// BM25 分数
|
|||
|
|
try {
|
|||
|
|
const bm25Params = analyzer.buildBM25Params ? analyzer.buildBM25Params(index) : null;
|
|||
|
|
const queryTokens = analyzer.tokenize(query);
|
|||
|
|
const skill = index.skills.find(s => s.name === skillName);
|
|||
|
|
if (skill && bm25Params) {
|
|||
|
|
const { totalScore } = analyzer.scoreSkill(skill, queryTokens, bm25Params);
|
|||
|
|
signals.bm25 = totalScore;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
// 语义分数
|
|||
|
|
try {
|
|||
|
|
if (semanticScorer) {
|
|||
|
|
const results = semanticScorer.semanticScore(query, index);
|
|||
|
|
const match = results.find(r => r.name === skillName);
|
|||
|
|
if (match) signals.semantic = match.score;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
// context/project/workflow 无法回溯,保持 0
|
|||
|
|
return signals;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 在线梯度下降学习融合权重
|
|||
|
|
* 思路: 对每条纠正,计算正确技能和错误技能的信号向量差异,
|
|||
|
|
* 沿着提升正确技能分数的方向调整权重
|
|||
|
|
*/
|
|||
|
|
function learnFusionWeights(options = {}) {
|
|||
|
|
const dryRun = options.dryRun || false;
|
|||
|
|
const corrections = loadCorrections();
|
|||
|
|
|
|||
|
|
if (corrections.length < 2) {
|
|||
|
|
return { status: 'skip', reason: '纠正不足2条', count: corrections.length };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载依赖
|
|||
|
|
let analyzer, semanticScorer;
|
|||
|
|
try { analyzer = require('./route-analyzer.js'); } catch { return { status: 'error', reason: 'route-analyzer 不可用' }; }
|
|||
|
|
try { semanticScorer = require('./semantic-scorer.js'); } catch { semanticScorer = null; }
|
|||
|
|
|
|||
|
|
let index;
|
|||
|
|
try { index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8')); } catch { return { status: 'error', reason: 'skills-index.json 不可用' }; }
|
|||
|
|
|
|||
|
|
const weights = loadWeights();
|
|||
|
|
const now = Date.now();
|
|||
|
|
let totalGradient = { bm25: 0, semantic: 0, context: 0, project: 0, workflow: 0 };
|
|||
|
|
let effectiveSamples = 0;
|
|||
|
|
|
|||
|
|
for (const fb of corrections) {
|
|||
|
|
// 时间衰减
|
|||
|
|
const age = now - new Date(fb.ts).getTime();
|
|||
|
|
if (isNaN(age) || age < 0) continue;
|
|||
|
|
const decay = Math.pow(0.5, age / DECAY_HALF_LIFE);
|
|||
|
|
|
|||
|
|
// 类型权重
|
|||
|
|
const typeFactor = fb.type === 'observed' ? 0.8 : (fb.type === 'implicit' ? 0.5 : 1.0);
|
|||
|
|
const sampleWeight = decay * typeFactor;
|
|||
|
|
if (sampleWeight < 0.01) continue;
|
|||
|
|
|
|||
|
|
// 模拟正确技能和错误技能的信号
|
|||
|
|
const correctSignals = simulateSignals(fb.query, fb.correctedTo, index, analyzer, semanticScorer);
|
|||
|
|
const wrongSignals = simulateSignals(fb.query, fb.routedTo, index, analyzer, semanticScorer);
|
|||
|
|
|
|||
|
|
// 梯度: 正确技能信号 - 错误技能信号 (希望提升正确技能的权重组合分数)
|
|||
|
|
for (const key of Object.keys(totalGradient)) {
|
|||
|
|
totalGradient[key] += sampleWeight * (correctSignals[key] - wrongSignals[key]);
|
|||
|
|
}
|
|||
|
|
effectiveSamples += sampleWeight;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (effectiveSamples < 1) {
|
|||
|
|
return { status: 'skip', reason: '有效样本不足', effectiveSamples };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 归一化梯度
|
|||
|
|
for (const key of Object.keys(totalGradient)) {
|
|||
|
|
totalGradient[key] /= effectiveSamples;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 梯度更新 + W1 权重衰减 (L2 正则化拉回 DEFAULT_WEIGHTS)
|
|||
|
|
const newWeights = { ...weights };
|
|||
|
|
for (const key of Object.keys(newWeights)) {
|
|||
|
|
newWeights[key] += LEARNING_RATE * totalGradient[key];
|
|||
|
|
newWeights[key] += WEIGHT_DECAY * (DEFAULT_WEIGHTS[key] - newWeights[key]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 投影到约束空间
|
|||
|
|
const projected = projectToSimplex(newWeights);
|
|||
|
|
|
|||
|
|
// 计算权重变化量
|
|||
|
|
const deltas = {};
|
|||
|
|
let totalDelta = 0;
|
|||
|
|
for (const key of Object.keys(projected)) {
|
|||
|
|
deltas[key] = Math.round((projected[key] - weights[key]) * 1000) / 1000;
|
|||
|
|
totalDelta += Math.abs(deltas[key]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = {
|
|||
|
|
status: 'ok',
|
|||
|
|
corrections: corrections.length,
|
|||
|
|
effectiveSamples: Math.round(effectiveSamples * 10) / 10,
|
|||
|
|
previousWeights: weights,
|
|||
|
|
newWeights: projected,
|
|||
|
|
deltas,
|
|||
|
|
totalDelta: Math.round(totalDelta * 1000) / 1000,
|
|||
|
|
gradient: Object.fromEntries(
|
|||
|
|
Object.entries(totalGradient).map(([k, v]) => [k, Math.round(v * 1000) / 1000])
|
|||
|
|
),
|
|||
|
|
dryRun,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (!dryRun && totalDelta > 0.005) {
|
|||
|
|
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
|||
|
|
const output = {
|
|||
|
|
generated: new Date().toISOString(),
|
|||
|
|
weights: projected,
|
|||
|
|
meta: {
|
|||
|
|
corrections: corrections.length,
|
|||
|
|
effectiveSamples: result.effectiveSamples,
|
|||
|
|
gradient: result.gradient,
|
|||
|
|
previousWeights: weights,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
// P1-2 修复: 使用文件锁保护写入,防止与 applyImplicitWeights 竞态
|
|||
|
|
// 根因守卫 + cooldown: 连续 2 周期命中才真正 reset,避免单周期误报抖动
|
|||
|
|
const wVals = Object.values(result.newWeights || {});
|
|||
|
|
let degen = 0;
|
|||
|
|
for (let i = 0; i < wVals.length; i++) {
|
|||
|
|
for (let j = i + 1; j < wVals.length; j++) {
|
|||
|
|
if (Math.abs(wVals[i] - wVals[j]) < 0.01) degen++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const bm25Val = (result.newWeights && result.newWeights.bm25) || 1;
|
|||
|
|
const DEGEN_COOLDOWN_FILE = path.join(DEBUG_DIR, 'degen-counter.json');
|
|||
|
|
const hitDegen = degen >= 3 || bm25Val <= 0.06;
|
|||
|
|
let consecutiveHits = 0;
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(DEGEN_COOLDOWN_FILE)) {
|
|||
|
|
const c = JSON.parse(fs.readFileSync(DEGEN_COOLDOWN_FILE, 'utf8'));
|
|||
|
|
if (typeof c.hits === 'number') consecutiveHits = c.hits;
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
if (hitDegen) {
|
|||
|
|
consecutiveHits += 1;
|
|||
|
|
try {
|
|||
|
|
const _tmp = DEGEN_COOLDOWN_FILE + '.tmp.' + process.pid;
|
|||
|
|
fs.writeFileSync(_tmp, JSON.stringify({ hits: consecutiveHits, degen, bm25: bm25Val, ts: new Date().toISOString() }));
|
|||
|
|
fs.renameSync(_tmp, DEGEN_COOLDOWN_FILE);
|
|||
|
|
} catch {}
|
|||
|
|
if (consecutiveHits >= 2) {
|
|||
|
|
try {
|
|||
|
|
process.stderr.write('[fusion-weight] 连续 ' + consecutiveHits + ' 周期退化命中,reset 到 DEFAULT_WEIGHTS\n');
|
|||
|
|
atomicResetWeightsFile(FUSION_WEIGHTS_FILE, 'degen-consecutive-' + consecutiveHits);
|
|||
|
|
// 重置计数器
|
|||
|
|
try { fs.unlinkSync(DEGEN_COOLDOWN_FILE); } catch {}
|
|||
|
|
} catch {}
|
|||
|
|
return Object.assign({}, result, { status: 'reset-on-degeneracy', weights: Object.assign({}, DEFAULT_WEIGHTS) });
|
|||
|
|
}
|
|||
|
|
// 单周期命中: 记录但不 reset (cooldown 防抖)
|
|||
|
|
try { process.stderr.write('[fusion-weight] 退化单周期 (' + consecutiveHits + '/2),等待下次确认\n'); } catch {}
|
|||
|
|
} else {
|
|||
|
|
// 未命中: 清零计数器 (连续性要求)
|
|||
|
|
try { if (fs.existsSync(DEGEN_COOLDOWN_FILE)) fs.unlinkSync(DEGEN_COOLDOWN_FILE); } catch {}
|
|||
|
|
}
|
|||
|
|
safeWriteFusionWeights(FUSION_WEIGHTS_FILE, output);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 重置为默认权重
|
|||
|
|
*/
|
|||
|
|
function resetWeights() {
|
|||
|
|
atomicResetWeightsFile(FUSION_WEIGHTS_FILE, 'manual-reset');
|
|||
|
|
return { status: 'reset', weights: DEFAULT_WEIGHTS };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 将 implicit-feedback.js 的输出回流到 fusion-weights.json (F3-4)
|
|||
|
|
*
|
|||
|
|
* 修复问题: implicit-feedback 只写 route-feedback.jsonl,未触发融合权重更新。
|
|||
|
|
* 本函数从 route-feedback.jsonl 读取反馈信号,将其转化为 PGD 梯度输入,
|
|||
|
|
* 调用 learnFusionWeights() 更新 fusion-weights.json。
|
|||
|
|
*
|
|||
|
|
* 反馈权重规则(来自 strategic-evolution-v6.md S1-Phase1):
|
|||
|
|
* confirmed → 权重向当前方向微调 (正向梯度, weight factor 0.5)
|
|||
|
|
* corrected → 权重向正确技能方向调整 (weight factor 1.0)
|
|||
|
|
* timeout-confirm → 弱信号确认 (weight factor 0.1)
|
|||
|
|
* observed → 直接观测,最高可信度 (weight factor 0.8)
|
|||
|
|
*
|
|||
|
|
* @param {Object} [options]
|
|||
|
|
* @param {boolean} [options.dryRun=false] - 仅预览,不写文件
|
|||
|
|
* @param {number} [options.minNewFeedback=3] - 新反馈条数不足时跳过
|
|||
|
|
* @param {string} [options.cwd] - 工作目录(per-cwd 权重隔离)
|
|||
|
|
* @returns {Object} { status, applied, skipped, reason, newWeights, totalDelta }
|
|||
|
|
*/
|
|||
|
|
function applyImplicitWeights(options = {}) {
|
|||
|
|
const dryRun = options.dryRun || false;
|
|||
|
|
const minNewFeedback = options.minNewFeedback || 3;
|
|||
|
|
const cwd = options.cwd || null;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// XC6 修复: 如果 learnFusionWeights 在同一 Stop 周期内已运行,
|
|||
|
|
// 先读取最新权重(包含 learnFusionWeights 写入的结果),在此基础上叠加 implicit 梯度,
|
|||
|
|
// 而非用 applyImplicitWeights 结果覆盖 learnFusionWeights 的结果。
|
|||
|
|
// 通过 loadWeights() 获取最新状态(下方已调用),不再需要额外标记文件。
|
|||
|
|
|
|||
|
|
// P2.4 watermark: 若反馈文件 size 未变化 (>=300 bytes 阈值 ~= 3 条新反馈),跳过全量重算
|
|||
|
|
// 避免 1085+ 行反馈每次 Stop 都重放,语义不变 (仍然全量读取,只是跳过无新增的周期)
|
|||
|
|
const WATERMARK_FILE = path.join(DEBUG_DIR, 'implicit-feedback-watermark.json');
|
|||
|
|
const MIN_SIZE_DELTA = 300;
|
|||
|
|
let currentSize = 0;
|
|||
|
|
try { currentSize = fs.existsSync(FEEDBACK_FILE) ? fs.statSync(FEEDBACK_FILE).size : 0; } catch {}
|
|||
|
|
if (!options.ignoreWatermark && currentSize > 0) {
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(WATERMARK_FILE)) {
|
|||
|
|
const wm = JSON.parse(fs.readFileSync(WATERMARK_FILE, 'utf8'));
|
|||
|
|
if (typeof wm.lastSize === 'number') {
|
|||
|
|
// RT-V3+V4 修复: 文件被 truncate → 用 overwrite 代替 unlink 消除 unlink-race
|
|||
|
|
if (currentSize < wm.lastSize) {
|
|||
|
|
try {
|
|||
|
|
const _tmp = WATERMARK_FILE + '.tmp.' + process.pid;
|
|||
|
|
fs.writeFileSync(_tmp, JSON.stringify({ lastSize: 0, ts: new Date().toISOString(), reason: 'truncate-reset' }));
|
|||
|
|
fs.renameSync(_tmp, WATERMARK_FILE);
|
|||
|
|
} catch {}
|
|||
|
|
} else if (currentSize - wm.lastSize < MIN_SIZE_DELTA) {
|
|||
|
|
return { status: 'skip', reason: 'watermark no-new-feedback', lastSize: wm.lastSize, currentSize };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 读取 route-feedback.jsonl(含 implicit 反馈和 explicit 纠正)
|
|||
|
|
if (!fs.existsSync(FEEDBACK_FILE)) {
|
|||
|
|
return { status: 'skip', reason: 'route-feedback.jsonl 不存在', applied: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const allFeedback = fs.readFileSync(FEEDBACK_FILE, 'utf8')
|
|||
|
|
.split('\n').filter(Boolean)
|
|||
|
|
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|||
|
|
.filter(f => f && f.ts && f.routedTo);
|
|||
|
|
|
|||
|
|
// RT-V3+V4 修复: 反馈已完整读入内存 → tmp+rename 原子更新 watermark (防并发读取半字节)
|
|||
|
|
try {
|
|||
|
|
const _wmTmp = WATERMARK_FILE + '.tmp.' + process.pid;
|
|||
|
|
fs.writeFileSync(_wmTmp, JSON.stringify({ lastSize: currentSize, ts: new Date().toISOString() }));
|
|||
|
|
fs.renameSync(_wmTmp, WATERMARK_FILE);
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
if (allFeedback.length === 0) {
|
|||
|
|
return { status: 'skip', reason: '反馈文件为空', applied: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 只处理 implicit 和 observed 类型的反馈(explicit 已由 learnFusionWeights 消费)
|
|||
|
|
const implicitFeedback = allFeedback.filter(f =>
|
|||
|
|
f.type === 'implicit' || f.type === 'observed'
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (implicitFeedback.length < minNewFeedback) {
|
|||
|
|
return {
|
|||
|
|
status: 'skip',
|
|||
|
|
reason: `隐式反馈条数不足 (${implicitFeedback.length} < ${minNewFeedback})`,
|
|||
|
|
applied: 0,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载当前权重
|
|||
|
|
const currentWeights = loadWeights(cwd);
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
// 计算加权平均梯度
|
|||
|
|
// 策略: 对 confirmed/timeout-confirm 施加微弱正向梯度(不改变方向)
|
|||
|
|
// 对 corrected/observed-correct 施加明确的梯度(提升正确技能的信号分量)
|
|||
|
|
let totalGradient = { bm25: 0, semantic: 0, context: 0, project: 0, workflow: 0 };
|
|||
|
|
let totalWeight = 0;
|
|||
|
|
|
|||
|
|
// 加载依赖(用于重新计算信号分数)
|
|||
|
|
let analyzer, semanticScorer, index;
|
|||
|
|
try { analyzer = require('./route-analyzer.js'); } catch { analyzer = null; }
|
|||
|
|
try { semanticScorer = require('./semantic-scorer.js'); } catch { semanticScorer = null; }
|
|||
|
|
try { index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8')); } catch { index = null; }
|
|||
|
|
|
|||
|
|
for (const fb of implicitFeedback) {
|
|||
|
|
// 时间衰减(7天半衰期)
|
|||
|
|
const age = now - new Date(fb.ts).getTime();
|
|||
|
|
if (isNaN(age) || age < 0) continue;
|
|||
|
|
const decay = Math.pow(0.5, age / DECAY_HALF_LIFE);
|
|||
|
|
|
|||
|
|
// 类型权重
|
|||
|
|
let typeFactor;
|
|||
|
|
switch (fb.implicit) {
|
|||
|
|
case 'observed-correct': typeFactor = 0.8; break;
|
|||
|
|
case 'correct': typeFactor = 1.0; break;
|
|||
|
|
case 'confirm': typeFactor = 0.5; break;
|
|||
|
|
case 'observed-confirm': typeFactor = 0.5; break;
|
|||
|
|
case 'timeout-confirm': typeFactor = fb.weight || 0.1; break;
|
|||
|
|
default:
|
|||
|
|
// 根据 type 判断
|
|||
|
|
typeFactor = (fb.routedTo !== fb.correctedTo) ? 1.0 : 0.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sampleWeight = decay * typeFactor;
|
|||
|
|
if (sampleWeight < 0.005) continue;
|
|||
|
|
|
|||
|
|
// 只有纠正类型才需要计算信号差(确认类型梯度为 0,不改变权重方向)
|
|||
|
|
const isCorrected = fb.routedTo !== fb.correctedTo;
|
|||
|
|
|
|||
|
|
if (isCorrected && analyzer && index) {
|
|||
|
|
// 模拟正确技能和错误技能的信号分数
|
|||
|
|
const correctSignals = simulateSignals(
|
|||
|
|
fb.query || '', fb.correctedTo, index, analyzer, semanticScorer
|
|||
|
|
);
|
|||
|
|
const wrongSignals = simulateSignals(
|
|||
|
|
fb.query || '', fb.routedTo, index, analyzer, semanticScorer
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
for (const key of Object.keys(totalGradient)) {
|
|||
|
|
totalGradient[key] += sampleWeight * (correctSignals[key] - wrongSignals[key]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// P1-1 修复: confirmed 类型不计入 totalWeight,防止梯度稀释导致学习停滞
|
|||
|
|
if (isCorrected) {
|
|||
|
|
totalWeight += sampleWeight;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (totalWeight < 0.1) {
|
|||
|
|
return { status: 'skip', reason: '有效样本权重不足', applied: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 归一化梯度
|
|||
|
|
for (const key of Object.keys(totalGradient)) {
|
|||
|
|
totalGradient[key] /= totalWeight;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 应用梯度更新(保守学习率,使用 LEARNING_RATE 的一半避免与 learnFusionWeights 叠加)
|
|||
|
|
// W1: 同步使用 WEIGHT_DECAY 的一半,与 implicitLR 成比例
|
|||
|
|
const implicitLR = LEARNING_RATE * 0.5;
|
|||
|
|
const implicitDecay = WEIGHT_DECAY * 0.5;
|
|||
|
|
const newWeights = { ...currentWeights };
|
|||
|
|
for (const key of Object.keys(newWeights)) {
|
|||
|
|
newWeights[key] += implicitLR * (totalGradient[key] || 0);
|
|||
|
|
newWeights[key] += implicitDecay * (DEFAULT_WEIGHTS[key] - newWeights[key]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 投影到概率单纯形
|
|||
|
|
const projected = projectToSimplex(newWeights);
|
|||
|
|
|
|||
|
|
// 计算变化量
|
|||
|
|
const deltas = {};
|
|||
|
|
let totalDelta = 0;
|
|||
|
|
for (const key of Object.keys(projected)) {
|
|||
|
|
deltas[key] = Math.round((projected[key] - currentWeights[key]) * 1000) / 1000;
|
|||
|
|
totalDelta += Math.abs(deltas[key]);
|
|||
|
|
}
|
|||
|
|
totalDelta = Math.round(totalDelta * 1000) / 1000;
|
|||
|
|
|
|||
|
|
// 变化量过小则跳过(避免频繁写文件)
|
|||
|
|
if (totalDelta < 0.003) {
|
|||
|
|
return {
|
|||
|
|
status: 'skip',
|
|||
|
|
reason: `梯度变化量过小 (${totalDelta}),跳过写入`,
|
|||
|
|
applied: implicitFeedback.length,
|
|||
|
|
totalDelta,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 写入 fusion-weights.json(非 dry-run 时)
|
|||
|
|
if (!dryRun) {
|
|||
|
|
const weightsFile = getWeightsFile(cwd);
|
|||
|
|
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
|||
|
|
const output = {
|
|||
|
|
generated: new Date().toISOString(),
|
|||
|
|
weights: projected,
|
|||
|
|
meta: {
|
|||
|
|
source: 'applyImplicitWeights',
|
|||
|
|
implicitFeedbackCount: implicitFeedback.length,
|
|||
|
|
effectiveWeight: Math.round(totalWeight * 10) / 10,
|
|||
|
|
gradient: Object.fromEntries(
|
|||
|
|
Object.entries(totalGradient).map(([k, v]) => [k, Math.round(v * 1000) / 1000])
|
|||
|
|
),
|
|||
|
|
previousWeights: currentWeights,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
// P1-2 修复: 使用文件锁保护写入,防止与 learnFusionWeights 竞态
|
|||
|
|
safeWriteFusionWeights(weightsFile, output);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
status: 'ok',
|
|||
|
|
applied: implicitFeedback.length,
|
|||
|
|
effectiveWeight: Math.round(totalWeight * 10) / 10,
|
|||
|
|
previousWeights: currentWeights,
|
|||
|
|
newWeights: projected,
|
|||
|
|
deltas,
|
|||
|
|
totalDelta,
|
|||
|
|
gradient: Object.fromEntries(
|
|||
|
|
Object.entries(totalGradient).map(([k, v]) => [k, Math.round(v * 1000) / 1000])
|
|||
|
|
),
|
|||
|
|
dryRun,
|
|||
|
|
};
|
|||
|
|
} catch (err) {
|
|||
|
|
// fail-open: 任何异常不影响主流程
|
|||
|
|
return { status: 'error', reason: err.message, applied: 0 };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始化引导: 若 fusion-weights.json 不存在,写入默认权重
|
|||
|
|
* 确保 route-interceptor-bundle 首次运行时有权重可读
|
|||
|
|
*/
|
|||
|
|
function bootstrapWeights(cwd) {
|
|||
|
|
const file = cwd ? getWeightsFile(cwd) : FUSION_WEIGHTS_FILE;
|
|||
|
|
if (fs.existsSync(file)) return { status: 'exists' };
|
|||
|
|
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
|||
|
|
const output = {
|
|||
|
|
generated: new Date().toISOString(),
|
|||
|
|
weights: { ...DEFAULT_WEIGHTS },
|
|||
|
|
meta: { source: 'bootstrap', corrections: 0 },
|
|||
|
|
};
|
|||
|
|
safeWriteFusionWeights(file, output);
|
|||
|
|
return { status: 'bootstrapped', weights: DEFAULT_WEIGHTS };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* H2 修复: 原子化 bootstrap + learn + apply 权重更新
|
|||
|
|
* 用 sentinel 文件检测"半写入"状态 (Stop hook 被 timeout kill)
|
|||
|
|
*
|
|||
|
|
* - 检测到 30s 内的 sentinel → 跳过 (并发写入保护)
|
|||
|
|
* - 检测到 >30s 的 stale sentinel → 清除后正常跑 (上次 kill 残留)
|
|||
|
|
* - 正常流程: 串联 bootstrap → learn → apply,过程中维护 sentinel
|
|||
|
|
*/
|
|||
|
|
function atomicWeightUpdate(options = {}) {
|
|||
|
|
const sentinel = FUSION_WEIGHTS_FILE + '.writing';
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
if (fs.existsSync(sentinel)) {
|
|||
|
|
try {
|
|||
|
|
const age = now - fs.statSync(sentinel).mtimeMs;
|
|||
|
|
if (age > 30000) {
|
|||
|
|
// Stale sentinel (上次写入被 kill) → 清除
|
|||
|
|
try {
|
|||
|
|
process.stderr.write('[fusion-weight] stale sentinel 清除 (age=' + Math.round(age/1000) + 's)\n');
|
|||
|
|
} catch {}
|
|||
|
|
try { fs.unlinkSync(sentinel); } catch {}
|
|||
|
|
} else {
|
|||
|
|
return { status: 'skip', reason: 'concurrent-write', age };
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
|||
|
|
fs.writeFileSync(sentinel, String(process.pid) + '@' + now);
|
|||
|
|
|
|||
|
|
const bootResult = bootstrapWeights();
|
|||
|
|
const learnResult = learnFusionWeights(options);
|
|||
|
|
const applyResult = applyImplicitWeights(options);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
status: 'ok',
|
|||
|
|
bootstrap: bootResult && bootResult.status,
|
|||
|
|
learn: learnResult && learnResult.status,
|
|||
|
|
apply: applyResult && applyResult.status,
|
|||
|
|
};
|
|||
|
|
} catch (err) {
|
|||
|
|
return { status: 'error', reason: err.message };
|
|||
|
|
} finally {
|
|||
|
|
try { if (fs.existsSync(sentinel)) fs.unlinkSync(sentinel); } catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 模块导出
|
|||
|
|
if (typeof module !== 'undefined') {
|
|||
|
|
module.exports = {
|
|||
|
|
loadWeights, projectToSimplex, learnFusionWeights,
|
|||
|
|
resetWeights, applyImplicitWeights, bootstrapWeights, atomicWeightUpdate,
|
|||
|
|
DEFAULT_WEIGHTS, FUSION_WEIGHTS_FILE,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CLI 入口
|
|||
|
|
if (require.main === module) {
|
|||
|
|
const args = process.argv.slice(2);
|
|||
|
|
const jsonMode = args.includes('--json');
|
|||
|
|
const dryRun = args.includes('--dry-run');
|
|||
|
|
|
|||
|
|
if (args.includes('--reset')) {
|
|||
|
|
const r = resetWeights();
|
|||
|
|
console.log(jsonMode ? JSON.stringify(r, null, 2) : '融合权重已重置为默认值');
|
|||
|
|
process.exit(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = learnFusionWeights({ dryRun });
|
|||
|
|
|
|||
|
|
if (jsonMode) {
|
|||
|
|
console.log(JSON.stringify(result, null, 2));
|
|||
|
|
} else {
|
|||
|
|
console.log('=== 融合权重自适应学习 ===');
|
|||
|
|
if (result.status === 'skip') {
|
|||
|
|
console.log(`跳过: ${result.reason}`);
|
|||
|
|
} else if (result.status === 'error') {
|
|||
|
|
console.log(`错误: ${result.reason}`);
|
|||
|
|
} else {
|
|||
|
|
console.log(`纠正样本: ${result.corrections}, 有效样本: ${result.effectiveSamples}`);
|
|||
|
|
console.log(`梯度: ${JSON.stringify(result.gradient)}`);
|
|||
|
|
console.log(`\n权重变化:`);
|
|||
|
|
for (const [k, v] of Object.entries(result.deltas)) {
|
|||
|
|
const arrow = v > 0 ? '↑' : v < 0 ? '↓' : '=';
|
|||
|
|
console.log(` ${k.padEnd(10)} ${result.previousWeights[k]} → ${result.newWeights[k]} (${arrow}${Math.abs(v)})`);
|
|||
|
|
}
|
|||
|
|
console.log(`\n总变化量: ${result.totalDelta}${result.dryRun ? ' (dry-run)' : ''}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|