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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astra",
|
||||
"version": "2.2.224",
|
||||
"version": "2.2.225",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astra",
|
||||
"version": "2.2.224",
|
||||
"version": "2.2.225",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
|
||||
+5
-1
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||
"version": "2.2.224",
|
||||
"version": "2.2.225",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -78,6 +78,10 @@
|
||||
"command": "g1nation.sleepDigest.runNow",
|
||||
"title": "Astra: 지식 사전 소화 지금 실행 (Sleep-time Digest)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.conflictScan.runNow",
|
||||
"title": "Astra: 지식 충돌 스캔 지금 실행 (신규 문서 ↔ 기존 지식)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.exportChat",
|
||||
"title": "Astra: Export Chat as Markdown"
|
||||
|
||||
@@ -174,20 +174,28 @@ const criticLoopHook: PostAnswerHook = {
|
||||
if (cfg.criticLoopEnabled === false) return;
|
||||
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
|
||||
|
||||
// 게이트 — 결정론적 검사가 문제를 신호한 업무 turn 에만 LLM 검수 1회
|
||||
// (로컬 모델 latency 보호: 깨끗한 답변에는 안 돈다).
|
||||
// 게이트 — 문제 신호가 있는 turn 에만 LLM 검수 1회 (로컬 latency 보호).
|
||||
// 트리거 2종:
|
||||
// (a) 업무 turn: 결정론적 검사가 요소 누락 또는 저확신(<70)을 신호
|
||||
// (b) 일반 turn: 두뇌 근거 약함(topScore<0.25)인데 답변이 수치·날짜·단정
|
||||
// 표현을 포함 — intrinsic self-correction 은 효과 없으므로(2310.01798)
|
||||
// 검수는 외부 신호(근거 약함이라는 검색 측정값)가 있을 때만 발동.
|
||||
const task = detectTaskType(ctx.userPrompt);
|
||||
if (!task) return;
|
||||
const coverage = checkRequirementCoverage(ctx.userPrompt, ctx.assistantAnswer);
|
||||
const retrievalSignals = ctx.confidenceSignals ?? {
|
||||
chunkCount: 0, topScore: 0, conflictCount: 0, ambiguityDetected: false,
|
||||
};
|
||||
const ASSERTIVE_RE = /\d{4}년|\d+월\s*\d+일|\d+(\.\d+)?\s*(%|배|건|개|원|달러)|입니다|확실|반드시|분명히/;
|
||||
const weakAssertive = retrievalSignals.topScore < 0.25
|
||||
&& ctx.assistantAnswer.length > 200
|
||||
&& ASSERTIVE_RE.test(ctx.assistantAnswer);
|
||||
if (!task && !weakAssertive) return;
|
||||
const coverage = checkRequirementCoverage(ctx.userPrompt, ctx.assistantAnswer);
|
||||
const answerSignals = extractAnswerSignals(
|
||||
ctx.assistantAnswer,
|
||||
coverage.ran ? coverage.missing.length : null,
|
||||
);
|
||||
const confidence = computeConfidence(retrievalSignals, answerSignals);
|
||||
const needsReview = (coverage.ran && coverage.missing.length > 0) || confidence.score < 70;
|
||||
const needsReview = (coverage.ran && coverage.missing.length > 0) || confidence.score < 70 || weakAssertive;
|
||||
if (!needsReview) return;
|
||||
|
||||
const critique = await runCriticReview({
|
||||
|
||||
@@ -42,6 +42,7 @@ import { startStocksWatcher } from './features/stocks';
|
||||
import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
|
||||
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
|
||||
import { ensureEmbeddingConfigured } from './extension/embeddingBootstrap';
|
||||
import { ensureFeatureInventory } from './extension/featureInventory';
|
||||
import { startGrowthCycleWatcher, runGrowthCycleOnce } from './features/growth/growthCycleWatcher';
|
||||
import { startSleepDigestWatcher, runSleepDigestOnce } from './features/growth/sleepDigest';
|
||||
import { registerProviderCommands } from './extension/providerCommands';
|
||||
@@ -78,6 +79,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// 비차단 (TF-IDF 검색은 이미 동작 중 — 결과를 기다릴 필요 없음).
|
||||
void ensureEmbeddingConfigured(context);
|
||||
|
||||
// 기능 인벤토리 — 버전 변경 시 package.json 에서 자기 기능 문서를 기계 생성해
|
||||
// 두뇌에 기록. 자기 평가가 구식 스냅샷 대신 항상 현행 소스를 근거로 하게 한다.
|
||||
void ensureFeatureInventory(context);
|
||||
|
||||
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
|
||||
initAstraPathResolver(context);
|
||||
|
||||
@@ -341,6 +346,12 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const summary = await runSleepDigestOnce();
|
||||
vscode.window.showInformationMessage(`사전 소화 완료 — ${summary}`);
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('g1nation.conflictScan.runNow', async () => {
|
||||
const { runConflictScanOnce } = await import('./features/growth/conflictScan');
|
||||
vscode.window.showInformationMessage('지식 충돌 스캔 실행 중…');
|
||||
const summary = await runConflictScanOnce();
|
||||
vscode.window.showInformationMessage(`충돌 스캔 완료 — ${summary}`);
|
||||
}));
|
||||
|
||||
// 7. Auto-open all three Astra webviews as tabs in editor column 3.
|
||||
// The sidebar/activity-bar entry point was removed in 2.81 — all three views
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* ASTRA 기능 인벤토리 자동 생성 — 자기 지식 구식화의 근본 수정.
|
||||
*
|
||||
* 문제 (반복 발생한 심각 버그): ASTRA 의 자기 지식이 *사람이 쓴 스냅샷*
|
||||
* (selfIdentity 블록, "ASTRA 자기 아키텍처" 위키 문서)에 의존했다. 스냅샷은
|
||||
* 작성 시점에 박제되므로 릴리스마다 구식이 되고, ASTRA 는 자기 평가·개선 제안에서
|
||||
* 이미 있는 기능을 "신규 제안"하거나(자가검증 구식 정보 사용) 없는 기능을 주장한다.
|
||||
*
|
||||
* 수정: 소스 오브 트루스(package.json 의 contributes.commands / configuration +
|
||||
* POST_ANSWER_HOOKS 레지스트리)에서 인벤토리 문서를 **활성화 시점에 기계 생성**해
|
||||
* 두뇌에 쓴다. 버전이 바뀌면 자동 재생성 — 사람이 갱신을 잊을 수 없는 구조.
|
||||
* RAG 가 이 문서를 검색하므로 자기 기능 질문·자기 개선 제안의 근거가 항상 현행이다.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getActiveBrainProfile, logError, logInfo } from '../utils';
|
||||
import { POST_ANSWER_HOOKS } from '../agent/postAnswerHooks';
|
||||
|
||||
export const INVENTORY_FILE = 'ASTRA 기능 인벤토리.md';
|
||||
const STATE_KEY = 'astra.inventoryVersion';
|
||||
|
||||
/** 답변 후 훅 id → 한 줄 설명. 레지스트리에 새 훅이 추가되면 id 는 자동 노출되고
|
||||
* 설명만 여기 한 줄 추가 (설명 누락 시에도 id 는 문서에 나타난다 — 침묵 누락 방지). */
|
||||
const HOOK_DESCRIPTIONS: Record<string, string> = {
|
||||
'devil-rebuttal': 'Devil Agent 반박 카드 (활성화 시)',
|
||||
'self-check': '답변 검증 LLM 호출 — 검색 근거 대조 (opt-in)',
|
||||
'term-validator': '글로서리 금지 용어 결정론적 검사',
|
||||
'requirement-coverage': '업무 필수 요소 커버리지 결정론적 검사',
|
||||
'confidence-escalation': '확신도 산출 + 인간 검토 에스컬레이션 + Reflection 기록',
|
||||
'critic-loop': '문제 신호(요소 누락/저확신/근거 약함+단정) 턴만 Critic LLM 검수 1회',
|
||||
};
|
||||
|
||||
function stripMd(s: string): string {
|
||||
return (s || '').replace(/\*\*|`|\[|\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** package.json contributes 에서 인벤토리 마크다운 생성 (순수 — 테스트 가능). */
|
||||
export function buildInventoryMarkdown(pkg: any, nowIso: string): string {
|
||||
const version = String(pkg?.version || '?');
|
||||
const commands: Array<{ command: string; title: string }> = pkg?.contributes?.commands || [];
|
||||
const configProps: Record<string, any> = pkg?.contributes?.configuration?.properties || {};
|
||||
|
||||
const lines: string[] = [
|
||||
'---',
|
||||
'type: reference',
|
||||
'title: "ASTRA 기능 인벤토리 (자동 생성)"',
|
||||
`version: "${version}"`,
|
||||
`generated_at: ${nowIso}`,
|
||||
'aliases: ["ASTRA 기능 목록", "ASTRA 명령어", "내 기능", "ASTRA가 할 수 있는 것", "기능 인벤토리", "ASTRA capabilities"]',
|
||||
'---',
|
||||
'',
|
||||
`# ASTRA 기능 인벤토리 — v${version} (자동 생성)`,
|
||||
'',
|
||||
'> ⚙️ 이 문서는 Astra 활성화 시 **소스 코드(package.json)에서 기계 생성**됩니다 — 수동 편집 금지 (버전 변경 시 덮어씀).',
|
||||
'> 자기 기능에 대한 질문·자기 개선 제안은 이 문서가 **항상 현행** 근거입니다. 서사적 설명은 [[ASTRA 자기 아키텍처]] 참고.',
|
||||
'',
|
||||
`## 사용자 명령 (${commands.length}개)`,
|
||||
...commands.map(c => `- ${stripMd(c.title)}`),
|
||||
'',
|
||||
`## 설정으로 제어되는 동작·자동화 (${Object.keys(configProps).length}개)`,
|
||||
...Object.entries(configProps).map(([key, prop]: [string, any]) => {
|
||||
const desc = stripMd(String(prop?.markdownDescription || prop?.description || ''));
|
||||
const first = desc.split(/(?<=[.다음])\s/)[0] || desc;
|
||||
return `- \`${key.replace('g1nation.', '')}\` — ${first.slice(0, 160)}`;
|
||||
}),
|
||||
'',
|
||||
`## 답변 후 자동 검증 훅 (${POST_ANSWER_HOOKS.length}단계 — 매 답변 후 실행)`,
|
||||
...POST_ANSWER_HOOKS.map(h => `- \`${h.id}\` — ${HOOK_DESCRIPTIONS[h.id] || '(설명 미등록 — 코드 참조)'}`),
|
||||
'',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전이 바뀌었거나 문서가 없으면 두뇌에 인벤토리를 재생성. 활성화 시 1회 호출
|
||||
* (fire-and-forget — 실패해도 turn 에 영향 없음).
|
||||
*/
|
||||
export async function ensureFeatureInventory(context: vscode.ExtensionContext): Promise<void> {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) return;
|
||||
const pkg = vscode.extensions.getExtension('g1nation.astra')?.packageJSON;
|
||||
if (!pkg) return;
|
||||
const version = String(pkg.version || '?');
|
||||
const file = path.join(brain.localBrainPath, INVENTORY_FILE);
|
||||
if (context.globalState.get<string>(STATE_KEY) === version && fs.existsSync(file)) return;
|
||||
|
||||
fs.writeFileSync(file, buildInventoryMarkdown(pkg, new Date().toISOString()), 'utf8');
|
||||
await context.globalState.update(STATE_KEY, version);
|
||||
logInfo('기능 인벤토리 재생성.', { version, file });
|
||||
} catch (e: any) {
|
||||
logError('기능 인벤토리 생성 실패 (무시).', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -22,6 +22,7 @@ export function buildSelfIdentityBlock(): string {
|
||||
'- **레슨(경험 기억)**: 작업 경험에서 만들어진 교훈이 `lessons/`에 쌓여 이후 판단에 반영된다.',
|
||||
'- **평가·성장 루프(Self-Evolving OS)**: 골든셋 기반 검색/업무 평가, 성장 리포트, 학습 큐(Need Engine), 지식 노후 점검(Decay), 승인된 학습의 자동 실행(Research Agent)으로 지식의 질을 측정·개선한다.',
|
||||
'따라서 "스스로 성장하는가"라는 질문에는 "모델 가중치는 고정이지만, 시스템으로서 저는 위 메커니즘으로 세션을 넘어 지식·기억을 축적하며 성장합니다"가 정확한 답이다. "나는 학습하지 않는 정적 모델"이라는 일반론으로 답하지 말 것. 자신의 기능·구조에 대한 상세 질문은 두뇌의 "ASTRA 자기 아키텍처" 문서를 근거로 답하라.',
|
||||
'[자기 평가·개선 제안 규칙] 자신의 기능을 평가하거나 새 기능을 제안하기 전에 반드시 두뇌의 "ASTRA 기능 인벤토리" 문서(소스 코드에서 자동 생성 — 항상 현행)와 대조하라. 인벤토리에 이미 있는 기능을 신규 제안하지 말고, "현재 X가 있고, 빠진 증분은 Y"의 형태로 말하라. 기억에 의존한 기능 목록 서술은 구식일 수 있다.',
|
||||
'[출력 위생] 자연스러운 한국어로 쓰고, 한 단어 안에 한글과 영문 알파벳을 섞지 마라("응다", "텍록", "결ently" 같은 깨진 합성 표기 금지). 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓴다.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 기능 인벤토리 자동 생성 + 충돌 스캔 대상 필터 — 순수 로직 테스트.
|
||||
* (자기 지식 구식화 버그의 근본 수정: 인벤토리가 package.json 에서 기계 생성되는지)
|
||||
*/
|
||||
import { buildInventoryMarkdown } from '../src/extension/featureInventory';
|
||||
import { isScanTarget } from '../src/features/growth/conflictScan';
|
||||
|
||||
describe('buildInventoryMarkdown — package.json 이 소스 오브 트루스', () => {
|
||||
const pkg = {
|
||||
version: '9.9.9',
|
||||
contributes: {
|
||||
commands: [
|
||||
{ command: 'g1nation.a', title: 'Astra: 기능 A' },
|
||||
{ command: 'g1nation.b', title: 'Astra: 기능 B (**굵게**)' },
|
||||
],
|
||||
configuration: {
|
||||
properties: {
|
||||
'g1nation.x.enabled': { type: 'boolean', markdownDescription: '**X 자동화** — 매일 실행합니다. 두 번째 문장.' },
|
||||
'g1nation.y': { type: 'string', description: 'Y 설정.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('버전·명령·설정·훅이 모두 들어간다', () => {
|
||||
const md = buildInventoryMarkdown(pkg, '2026-06-12T00:00:00Z');
|
||||
expect(md).toContain('v9.9.9');
|
||||
expect(md).toContain('사용자 명령 (2개)');
|
||||
expect(md).toContain('Astra: 기능 A');
|
||||
expect(md).toContain('기능 B (굵게)'); // 마크다운 장식 제거
|
||||
expect(md).toContain('`x.enabled`');
|
||||
expect(md).toContain('답변 후 자동 검증 훅');
|
||||
expect(md).toContain('`critic-loop`'); // 레지스트리에서 직접 — 코드가 곧 문서
|
||||
expect(md).toContain('수동 편집 금지');
|
||||
});
|
||||
|
||||
test('레지스트리의 모든 훅 id 가 노출된다 (침묵 누락 방지)', () => {
|
||||
const md = buildInventoryMarkdown(pkg, '2026-06-12T00:00:00Z');
|
||||
for (const id of ['devil-rebuttal', 'self-check', 'term-validator', 'requirement-coverage', 'confidence-escalation', 'critic-loop']) {
|
||||
expect(md).toContain(`\`${id}\``);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isScanTarget — 충돌 스캔 제외 규칙', () => {
|
||||
test.each([
|
||||
'Topics_Rag/새문서.md',
|
||||
'AI_and_ML/RAG.md',
|
||||
])('지식 본문은 대상: %s', (p) => expect(isScanTarget(p)).toBe(true));
|
||||
|
||||
test.each([
|
||||
'Digests/Topics_Rag.md',
|
||||
'lessons/2026-06-12-correction-x.md',
|
||||
'playbooks/p.md',
|
||||
'ASTRA 기능 인벤토리.md',
|
||||
'Topics_Rag/이미지.png',
|
||||
])('소화 노트·레슨·인벤토리·비-md 제외: %s', (p) => expect(isScanTarget(p)).toBe(false));
|
||||
});
|
||||
Reference in New Issue
Block a user