UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

326 lines (325 loc) 12.4 kB
/** * DecayDetector — 知识衰退检测 + 评分 * * 6 种衰退检测策略(任一满足即触发 decaying 转换): * 1. daysSinceLastHit > 90 — 90 天无使用 * 2. ruleFalsePositiveRate > 0.4 && triggers > 10 — 规则已不准 * 3. ReverseGuard: coreCode 引用的 API 符号已删除 * 3b. SourceRefReconciler: 来源文件路径失效(recipe_source_refs.status = stale) * 4. 同域新 Recipe 发布且 deprecated_by 关系指向它 * 5. ContradictionDetector: 与更新的 Recipe 硬矛盾 * * 衰退评分 (decayScore 0-100): * freshness(0.3) + usage(0.3) + quality(0.2) + authority(0.2) * * 80-100: 健康 → 不转换 * 60-79: 关注 → Dashboard 警告 * 40-59: 衰退 → active → decaying * 20-39: 严重 → Grace Period 缩短到 15d * 0-19: 死亡 → 跳过确认直接 deprecated */ var _a; import { and, like, sql } from 'drizzle-orm'; import { auditLogs } from '../../infrastructure/database/drizzle/schema.js'; import Logger from '../../infrastructure/logging/Logger.js'; /* ────────────────────── Helpers ────────────────────── */ /** * Normalize a timestamp to **milliseconds**. * If the value looks like Unix seconds (< 1e12 ≈ year 2001 in ms), multiply by 1000. * Otherwise assume it's already in ms and return as-is. */ function toMs(ts) { return ts < 1e12 ? ts * 1000 : ts; } /* ────────────────────── Constants ────────────────────── */ const DAY_MS = 24 * 60 * 60 * 1000; const GRACE_PERIOD_STANDARD = 30 * DAY_MS; const GRACE_PERIOD_SEVERE = 15 * DAY_MS; const DECAY_THRESHOLDS = { /** 无使用天数上限 */ NO_USAGE_DAYS: 90, /** FP 率上限 */ FALSE_POSITIVE_RATE: 0.4, /** FP 率可靠性所需最少触发次数 */ MIN_FP_TRIGGERS: 10, }; const SCORE_WEIGHTS = { freshness: 0.3, usage: 0.3, quality: 0.2, authority: 0.2, }; /* ────────────────────── Class ────────────────────── */ export class DecayDetector { #knowledgeRepo; #edgeRepo; #sourceRefRepo; #drizzle; #signalBus; #logger = Logger.getInstance(); constructor(knowledgeRepo, options = {}) { this.#knowledgeRepo = knowledgeRepo; this.#edgeRepo = options.knowledgeEdgeRepo ?? null; this.#sourceRefRepo = options.sourceRefRepo ?? null; this.#drizzle = options.drizzle ?? null; this.#signalBus = options.signalBus ?? null; } /** * 扫描所有 active 条目的衰退状态 */ async scanAll() { const recipes = await this.#loadActiveRecipes(); const results = []; for (const recipe of recipes) { const result = await this.evaluate(recipe); results.push(result); } // 发射衰退信号 if (this.#signalBus) { for (const r of results) { if (r.level !== 'healthy') { this.#signalBus.send('decay', 'DecayDetector', 1 - r.decayScore / 100, { target: r.recipeId, metadata: { level: r.level, decayScore: r.decayScore, signals: r.signals.map((s) => s.strategy), }, }); } } } this.#logger.debug(`DecayDetector: scanned ${results.length} recipes, ${results.filter((r) => r.level !== 'healthy').length} need attention`); return results; } /** * 评估单条 Recipe 的衰退状态 */ async evaluate(recipe) { const stats = _a.#parseStats(recipe.stats); const signals = []; const now = Date.now(); // 策略 1: 90 天无使用 const lastHitAt = stats.lastHitAt ?? null; if (lastHitAt) { const daysSince = (now - toMs(lastHitAt)) / DAY_MS; if (daysSince > DECAY_THRESHOLDS.NO_USAGE_DAYS) { signals.push({ recipeId: recipe.id, strategy: 'no_recent_usage', detail: `No usage in ${Math.round(daysSince)} days (threshold: ${DECAY_THRESHOLDS.NO_USAGE_DAYS}d)`, }); } } else { // 无 lastHitAt,检查创建时间(DB 可能存为秒或毫秒) const createdAt = toMs(recipe.created_at ?? now); const daysSinceCreation = (now - createdAt) / DAY_MS; if (daysSinceCreation > DECAY_THRESHOLDS.NO_USAGE_DAYS) { signals.push({ recipeId: recipe.id, strategy: 'no_recent_usage', detail: `Never used, created ${Math.round(daysSinceCreation)} days ago`, }); } } // 策略 2: 高 FP 率 const fpRate = stats.ruleFalsePositiveRate ?? 0; const triggers = stats.guardHits ?? 0; if (fpRate > DECAY_THRESHOLDS.FALSE_POSITIVE_RATE && triggers >= DECAY_THRESHOLDS.MIN_FP_TRIGGERS) { signals.push({ recipeId: recipe.id, strategy: 'high_false_positive', detail: `FP rate ${(fpRate * 100).toFixed(0)}% with ${triggers} triggers (threshold: ${DECAY_THRESHOLDS.FALSE_POSITIVE_RATE * 100}%)`, }); } // 策略 3: 符号漂移(由 ReverseGuard 提供,此处从 DB 查 drift 标记) if (await this.#hasSymbolDrift(recipe.id)) { signals.push({ recipeId: recipe.id, strategy: 'symbol_drift', detail: 'ReverseGuard detected symbol drift in coreCode', }); } // 策略 3b: 来源引用失效(由 SourceRefReconciler 填充 recipe_source_refs) const staleRefCount = await this.#getStaleSourceRefCount(recipe.id); if (staleRefCount > 0) { signals.push({ recipeId: recipe.id, strategy: 'source_ref_stale', detail: `${staleRefCount} source reference(s) no longer exist on disk`, }); } // 策略 4: 被取代(有 deprecated_by 关系指向更新版本) if (await this.#isSuperseded(recipe.id)) { signals.push({ recipeId: recipe.id, strategy: 'superseded', detail: 'Newer version exists via deprecated_by relation', }); } // 计算 decayScore(staleRatio 影响 quality 维度) const staleRatio = await this.#getSourceRefStaleRatio(recipe.id); const dimensions = this.#computeScoreDimensions(stats, recipe, { staleRatio }); const decayScore = Math.round(dimensions.freshness * SCORE_WEIGHTS.freshness * 100 + dimensions.usage * SCORE_WEIGHTS.usage * 100 + dimensions.quality * SCORE_WEIGHTS.quality * 100 + dimensions.authority * SCORE_WEIGHTS.authority * 100); const level = _a.#scoreToLevel(decayScore); const suggestedGracePeriod = level === 'dead' ? 0 : level === 'severe' ? GRACE_PERIOD_SEVERE : GRACE_PERIOD_STANDARD; return { recipeId: recipe.id, title: recipe.title, decayScore, level, signals, dimensions, suggestedGracePeriod, }; } /* ── Internal ── */ async #loadActiveRecipes() { try { const entries = await this.#knowledgeRepo.findAllByLifecycles(['active']); return entries.map((e) => { const qualityObj = typeof e.quality === 'object' ? { grade: e.quality.grade ?? null, score: e.quality.overall ?? null, } : _a.#parseQuality(null); return { id: e.id, title: e.title, lifecycle: e.lifecycle, stats: typeof e.stats === 'object' ? JSON.stringify(e.stats) : null, quality_grade: qualityObj.grade, quality_score: qualityObj.score, created_at: e.createdAt ?? null, }; }); } catch { return []; } } static #parseStats(statsJson) { if (!statsJson) { return {}; } try { return JSON.parse(statsJson); } catch { return {}; } } static #parseQuality(qualityJson) { if (!qualityJson) { return { grade: null, score: null }; } try { const obj = JSON.parse(qualityJson); return { grade: typeof obj.grade === 'string' ? obj.grade : null, score: typeof obj.overall === 'number' ? obj.overall : null, }; } catch { return { grade: null, score: null }; } } #computeScoreDimensions(stats, recipe, context = {}) { const now = Date.now(); // freshness: days since last hit → 0-1 (0 = 365+ days, 1 = today) const lastHit = stats.lastHitAt ?? 0; const daysSinceHit = lastHit > 0 ? (now - toMs(lastHit)) / DAY_MS : 365; const freshness = Math.max(0, 1 - daysSinceHit / 365); // usage: hitsLast90d 归一化 (0 = 0 hits, 1 = 50+ hits) const hitsLast90d = stats.hitsLast90d ?? 0; const usage = Math.min(1, hitsLast90d / 50); // quality: qualityScore × sourceRef 健康度 // staleRatio 对 quality 打折,最多压低 30%(全部 stale → ×0.7) const baseQuality = recipe.quality_score ?? 0.5; const staleRatio = context.staleRatio ?? 0; const quality = baseQuality * (1 - staleRatio * 0.3); // authority: from stats.authority 归一化 (0-100 → 0-1) const authorityRaw = stats.authority ?? 50; const authority = Math.min(1, authorityRaw / 100); return { freshness, usage, quality, authority }; } async #hasSymbolDrift(recipeId) { try { if (!this.#drizzle) { return false; } const row = this.#drizzle .select({ id: auditLogs.id }) .from(auditLogs) .where(and(like(auditLogs.action, '%ReverseGuard%'), sql `json_extract(${auditLogs.operationData}, '$.target') = ${recipeId}`)) .limit(1) .get(); return !!row; } catch { return false; } } async #getStaleSourceRefCount(recipeId) { try { if (!this.#sourceRefRepo) { return 0; } const refs = this.#sourceRefRepo.findByRecipeId(recipeId); return refs.filter((r) => r.status === 'stale').length; } catch { return 0; } } async #getSourceRefStaleRatio(recipeId) { try { if (!this.#sourceRefRepo) { return 0; } const refs = this.#sourceRefRepo.findByRecipeId(recipeId); if (refs.length === 0) { return 0; } const stale = refs.filter((r) => r.status === 'stale').length; return stale / refs.length; } catch { return 0; } } async #isSuperseded(recipeId) { try { if (!this.#edgeRepo) { return false; } const edges = await this.#edgeRepo.findByRelation(recipeId, 'recipe', 'deprecated_by'); return edges.length > 0; } catch { return false; } } static #scoreToLevel(score) { if (score >= 80) { return 'healthy'; } if (score >= 60) { return 'watch'; } if (score >= 40) { return 'decaying'; } if (score >= 20) { return 'severe'; } return 'dead'; } } _a = DecayDetector;