feat(growth): 자기 지식 자동화 + 회귀 경보 + 충돌 스캔 + Critic 게이트 확장 (v2.2.225)
[근본 수정 — 자가검증 구식 정보 버그] ASTRA 자기 지식이 사람이 쓴 스냅샷(selfIdentity 블록·아키텍처 위키 문서)에 의존해 릴리스마다 구식이 됐고, 자기 개선 제안에서 이미 있는 기능을 신규 제안하는 오류가 반복됨. 수정: - featureInventory.ts: 활성화 시 package.json(contributes.commands/configuration) + POST_ANSWER_HOOKS 레지스트리에서 "ASTRA 기능 인벤토리" 문서를 두뇌에 기계 생성 (버전 변경 시 자동 재생성 — 사람이 갱신을 잊을 수 없는 구조). - selfIdentity: "자기 기능 평가·제안 전 인벤토리와 대조, 기억 의존 서술 금지" 규칙. [검증-피드백-재설계 파이프라인 보강 — 의견 검토 후 역제안 3건] - A-1 골든셋 회귀 경보: 주간 사이클이 metrics-history.jsonl 적립 + 직전 대비 recall@1 -10%p 또는 MRR -0.08 하락 시 ⚠️ + 그 기간 추가된 문서를 용의자로 제시(regression-alert.md). 자동 롤백 없음 — 판단은 사람. - A-2 신규 지식 충돌 스캔(conflictScan.ts): 일일(사전 소화와 같은 슬롯) 신규/변경 문서를 기존 유사 top-2와 LLM 모순 비교 → 충돌 시 conflict-report.md + "기존 A vs 신규 B" 알림. 쓰기 주체(Datacollect/수동/Research) 무관 포착. 런당 비교 ≤5건·최초 실행 24h 한정 (폭주 방지). - A-3 criticLoop 게이트 확장: 업무 turn 외에도 "근거 약함(top<0.25) + 단정 표현(수치·날짜·확언)" 트리거 추가. 전 답변 강제 2-pass 는 기각 — intrinsic self-correction 은 외부 신호 없이 효과 없음(arXiv 2310.01798). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 신규 지식 충돌 스캔 — 지식 정합성의 "쓰기 시점" 게이트.
|
||||
*
|
||||
* 기존 충돌 감지(검색 시점 [CONFLICT WARNING])는 두 문서가 *우연히 같이 검색될 때*만
|
||||
* 동작한다. 이 스캔은 새로 추가·변경된 두뇌 문서를 매일 능동적으로 기존 유사 문서와
|
||||
* 대조해, 검색되기 전에 모순을 표면화한다 (Mem0 의 UPDATE/DELETE 판단의 보수적 변형 —
|
||||
* 시스템은 감지·보고만 하고 어느 쪽을 유지할지는 사람이 결정: Permission Based Learning).
|
||||
*
|
||||
* 흐름: [신규/변경 문서 감지(mtime)] → [유사 기존 문서 top-k 검색] → [LLM 모순 비교]
|
||||
* → [충돌 시 .astra/growth/conflict-report.md 기록 + 알림 "기존 A vs 신규 B"]
|
||||
*
|
||||
* 위키를 쓰는 주체(Datacollect 브리지·수동 편집·Research Agent)와 무관하게 전부 포착
|
||||
* — 쓰기 가로채기 대신 사후 스캔을 택한 이유.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
import { findBrainFiles, getActiveBrainProfile, logError, logInfo } from '../../utils';
|
||||
import { RetrievalOrchestrator } from '../../retrieval';
|
||||
import { getBrainTokenIndex } from '../../retrieval/brainIndex';
|
||||
import { simpleChatCompletion } from '../../intelligence/llmCall';
|
||||
import { DIGEST_DIR } from './sleepDigest';
|
||||
|
||||
const STATE_REL = path.join('.astra', 'growth', 'conflict-scan-state.json');
|
||||
const REPORT_REL = path.join('.astra', 'growth', 'conflict-report.md');
|
||||
const MAX_COMPARES_PER_RUN = 5;
|
||||
const NEIGHBOR_K = 2;
|
||||
|
||||
/** 스캔 제외 — 소화 노트·레슨·자동 생성 인벤토리는 지식 본문이 아니다. */
|
||||
export function isScanTarget(relPath: string): boolean {
|
||||
return !new RegExp(`(^|[\\\\/])(${DIGEST_DIR}|lessons?|playbooks?|qa[-_]?findings?)([\\\\/])`, 'i').test(relPath)
|
||||
&& !/기능 인벤토리/.test(relPath)
|
||||
&& relPath.toLowerCase().endsWith('.md');
|
||||
}
|
||||
|
||||
function loadLastScanMs(brainPath: string): number {
|
||||
try {
|
||||
const f = path.join(brainPath, STATE_REL);
|
||||
if (!fs.existsSync(f)) return 0;
|
||||
const v = JSON.parse(fs.readFileSync(f, 'utf8'))?.lastScanMs;
|
||||
return typeof v === 'number' ? v : 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
function saveLastScanMs(brainPath: string, ms: number): void {
|
||||
try {
|
||||
const f = path.join(brainPath, STATE_REL);
|
||||
fs.mkdirSync(path.dirname(f), { recursive: true });
|
||||
fs.writeFileSync(f, JSON.stringify({ lastScanMs: ms }) + '\n', 'utf8');
|
||||
} catch { /* 상태 저장 실패 — 다음 런이 더 넓게 스캔할 뿐 */ }
|
||||
}
|
||||
|
||||
const COMPARE_SYSTEM = [
|
||||
'너는 지식 정합성 검사기다. [신규 문서]와 [기존 문서]가 같은 주제에 대해 *모순되는 사실*을 말하는지 판정하라.',
|
||||
'모순 = 같은 대상에 대해 양립 불가능한 수치·날짜·결론·정의. 관점 차이·세부 수준 차이·다른 주제는 모순이 아니다.',
|
||||
'반드시 JSON 한 줄만 출력: {"conflict":true|false,"summary":"<모순 내용 한 문장, 없으면 빈 문자열>"}',
|
||||
].join('\n');
|
||||
|
||||
export interface ConflictFinding {
|
||||
newDoc: string;
|
||||
existingDoc: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** 1회 스캔 실행. 반환: 요약 문자열. */
|
||||
export async function runConflictScanOnce(): Promise<string> {
|
||||
const brain = getActiveBrainProfile();
|
||||
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) return '두뇌 없음 — skip';
|
||||
const config = getConfig();
|
||||
if (!config.defaultModel || !config.ollamaUrl) return 'LLM 미설정 — skip';
|
||||
|
||||
const brainPath = brain.localBrainPath;
|
||||
const lastScanMs = loadLastScanMs(brainPath);
|
||||
const scanStartMs = Date.now();
|
||||
const allFiles = findBrainFiles(brainPath);
|
||||
|
||||
// 신규/변경 대상 (최초 실행은 최근 24h 만 — 두뇌 전체 재스캔 방지)
|
||||
const cutoff = lastScanMs > 0 ? lastScanMs : scanStartMs - 86_400_000;
|
||||
const targets = allFiles
|
||||
.map(f => { try { return { f, m: fs.statSync(f).mtimeMs }; } catch { return null; } })
|
||||
.filter((x): x is { f: string; m: number } => !!x && x.m > cutoff)
|
||||
.filter(x => isScanTarget(path.relative(brainPath, x.f)))
|
||||
.sort((a, b) => b.m - a.m)
|
||||
.slice(0, MAX_COMPARES_PER_RUN);
|
||||
|
||||
if (targets.length === 0) { saveLastScanMs(brainPath, scanStartMs); return '신규 문서 없음'; }
|
||||
|
||||
getBrainTokenIndex(brainPath, allFiles);
|
||||
const orchestrator = new RetrievalOrchestrator();
|
||||
const findings: ConflictFinding[] = [];
|
||||
let compared = 0;
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
const rel = path.relative(brainPath, t.f);
|
||||
const content = fs.readFileSync(t.f, 'utf8');
|
||||
const title = path.basename(t.f, '.md');
|
||||
// 유사 기존 문서 — 자신 제외 상위 NEIGHBOR_K
|
||||
const neighbors = orchestrator
|
||||
.rankBrainForEval(`${title} ${content.slice(0, 500)}`, brain, { limit: NEIGHBOR_K + 3 })
|
||||
.filter(r => path.resolve(r.filePath) !== path.resolve(t.f))
|
||||
.slice(0, NEIGHBOR_K);
|
||||
for (const n of neighbors) {
|
||||
let existing = '';
|
||||
try { existing = fs.readFileSync(n.filePath, 'utf8'); } catch { continue; }
|
||||
const raw = await simpleChatCompletion(
|
||||
COMPARE_SYSTEM,
|
||||
`[신규 문서: ${title}]\n${content.slice(0, 3500)}\n\n[기존 문서: ${path.basename(n.relativePath, '.md')}]\n${existing.slice(0, 3500)}`,
|
||||
{ baseUrl: config.ollamaUrl, model: config.defaultModel, temperature: 0.1, maxTokens: 150, timeoutMs: 90000 },
|
||||
);
|
||||
compared++;
|
||||
const m = raw.match(/\{[\s\S]*?\}/);
|
||||
if (!m) continue;
|
||||
const parsed = JSON.parse(m[0]);
|
||||
if (parsed?.conflict === true && String(parsed?.summary || '').trim()) {
|
||||
findings.push({ newDoc: rel, existingDoc: n.relativePath, summary: String(parsed.summary).slice(0, 200) });
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('충돌 스캔: 문서 비교 실패 (계속).', { file: t.f, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
saveLastScanMs(brainPath, scanStartMs);
|
||||
|
||||
if (findings.length > 0) {
|
||||
const reportFile = path.join(brainPath, REPORT_REL);
|
||||
const block = [
|
||||
`\n## ${new Date().toLocaleString()} — 충돌 ${findings.length}건`,
|
||||
...findings.map(f => `- ⚔️ 신규 **${f.newDoc}** ↔ 기존 **${f.existingDoc}**: ${f.summary}\n → 어느 쪽을 유지할지 결정 후 다른 쪽을 수정/삭제하세요 (시스템은 덮어쓰지 않음).`),
|
||||
].join('\n');
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(reportFile), { recursive: true });
|
||||
if (!fs.existsSync(reportFile)) fs.writeFileSync(reportFile, '# 지식 충돌 리포트\n시스템은 감지·보고만 합니다 — 유지/수정 결정은 사람.\n', 'utf8');
|
||||
fs.appendFileSync(reportFile, block + '\n', 'utf8');
|
||||
} catch { /* 리포트 실패해도 알림은 전달 */ }
|
||||
void vscode.window.showWarningMessage(
|
||||
`⚔️ 지식 충돌 ${findings.length}건 감지 — 예: "${findings[0].newDoc}" ↔ "${findings[0].existingDoc}". conflict-report.md 에서 확인하세요.`,
|
||||
);
|
||||
}
|
||||
const summary = `신규 ${targets.length}건 스캔 · 비교 ${compared}회 · 충돌 ${findings.length}건`;
|
||||
logInfo('지식 충돌 스캔 완료.', { summary });
|
||||
return summary;
|
||||
}
|
||||
@@ -112,6 +112,38 @@ export async function runGrowthCycleOnce(context: vscode.ExtensionContext): Prom
|
||||
const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
fs.writeFileSync(path.join(brain.localBrainPath, '.astra', 'eval', `report-${stamp}-auto.md`), md, 'utf8');
|
||||
summary.push(`검색 recall@1 ${(report.recallAtK[1] * 100).toFixed(0)}% · MRR ${report.mrr.toFixed(2)}`);
|
||||
|
||||
// [회귀 경보] 직전 사이클 지표와 비교 — 성장(지식 추가)이 검색 품질을
|
||||
// 퇴보시키지 않았는지 감시. 하락 시 그 기간에 추가된 문서를 용의자로 제시.
|
||||
// 자동 롤백은 하지 않는다 (지식은 git 에 있고 판단은 사람 — 승인 원칙).
|
||||
try {
|
||||
const histFile = path.join(brain.localBrainPath, '.astra', 'eval', 'metrics-history.jsonl');
|
||||
let prev: { ts: string; recall1: number; mrr: number } | null = null;
|
||||
if (fs.existsSync(histFile)) {
|
||||
const rows = fs.readFileSync(histFile, 'utf8').trim().split('\n').filter(Boolean);
|
||||
if (rows.length > 0) { try { prev = JSON.parse(rows[rows.length - 1]); } catch { /* skip */ } }
|
||||
}
|
||||
const cur = { ts: now.toISOString(), recall1: report.recallAtK[1], recall3: report.recallAtK[3], mrr: report.mrr, queries: entries.length };
|
||||
fs.appendFileSync(histFile, JSON.stringify(cur) + '\n', 'utf8');
|
||||
const R1_DROP = 0.10, MRR_DROP = 0.08;
|
||||
if (prev && (prev.recall1 - cur.recall1 >= R1_DROP || prev.mrr - cur.mrr >= MRR_DROP)) {
|
||||
const prevTs = Date.parse(prev.ts) || 0;
|
||||
const suspects = allFiles
|
||||
.map(f => { try { return { f, m: fs.statSync(f).mtimeMs }; } catch { return null; } })
|
||||
.filter((x): x is { f: string; m: number } => !!x && x.m > prevTs)
|
||||
.sort((a, b) => b.m - a.m).slice(0, 10)
|
||||
.map(x => path.relative(brain.localBrainPath, x.f));
|
||||
const alert = [
|
||||
`# ⚠️ 검색 회귀 감지 — ${now.toLocaleString()}`, '',
|
||||
`recall@1 ${(prev.recall1 * 100).toFixed(0)}% → ${(cur.recall1 * 100).toFixed(0)}% · MRR ${prev.mrr.toFixed(2)} → ${cur.mrr.toFixed(2)}`,
|
||||
'', `## 직전 사이클 이후 추가·변경된 문서 (용의자 ${suspects.length}건)`,
|
||||
...suspects.map(s => `- ${s}`),
|
||||
'', '조치: 용의자 문서의 중복/제목 충돌 여부 확인. 복구는 git 으로 (자동 롤백 안 함 — 판단은 사람).',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(growthDir, 'regression-alert.md'), alert, 'utf8');
|
||||
summary.push(`⚠️ 검색 회귀 (recall@1 ${(prev.recall1 * 100).toFixed(0)}%→${(cur.recall1 * 100).toFixed(0)}%) — regression-alert.md 확인`);
|
||||
}
|
||||
} catch (e: any) { logError('성장 사이클: 회귀 비교 실패.', { error: e?.message ?? String(e) }); }
|
||||
}
|
||||
} catch (e: any) { logError('성장 사이클: 검색 평가 실패.', { error: e?.message ?? String(e) }); }
|
||||
|
||||
|
||||
@@ -248,6 +248,13 @@ function scheduleNext(): void {
|
||||
try { await runSleepDigestOnce(); } catch (e: any) {
|
||||
logError('Sleep digest 사이클 실패.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
// 같은 유휴 슬롯에 신규 지식 충돌 스캔도 수행 (쓰기 시점 정합성 게이트).
|
||||
try {
|
||||
const { runConflictScanOnce } = await import('./conflictScan');
|
||||
await runConflictScanOnce();
|
||||
} catch (e: any) {
|
||||
logError('충돌 스캔 실패 (무시).', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
scheduleNext();
|
||||
}, ms);
|
||||
|
||||
Reference in New Issue
Block a user