feat(growth): 지식 재구성 — 충돌 통합 초안 + A-MEM 레슨 네트워크 (v2.2.228)

ASTRA 자기 제안(검증 통과분) 1순위 구현: 충돌을 '감지'에서 '재구성'으로.

[충돌 → 통합 초안 (사람 승인 대기)]
- conflictScan: 모순 감지 시 LLM이 통합 초안 생성 → .astra/growth/reconcile/
  (런당 ≤3건). 신뢰 권고 우선 쪽 기준 + 타방의 유효 정보 보존 + 판단 불가
  사실은 "(확인 필요: A는 X, B는 Y)" 병기 + 출처 표기 강제.
- 자동 반영 절대 없음 — 초안 머리에 명기, 승인 시 사람이 직접 반영 (status:
  pending-review). 거부 = 파일 삭제.

[A-MEM 레슨 네트워크 (NeurIPS 2025 이식, deep research 2순위)]
- lessonNetwork.ts: 새 레슨 저장 시 기존 레슨과 토큰 자카드 유사도 상위 3개를
  "## 관련 레슨" [[위키링크]]로 연결 + 기존 레슨에 백링크(역방향 갱신 —
  memory evolution). LLM 호출 0 — 캡처 경로 지연 없음. 멱등(재실행 안전).
- 연결 지점: Correction Loop 자동 레슨 + 수동 레슨 생성(Astra: New Lesson).
  고립된 카드 모음 → 상호 연결 네트워크: "같은 종류의 실수" 패턴이 파일
  수준에서 보이고 RAG 위키링크로 함께 검색됨.

테스트 6건 추가 (유사도·링크 멱등·양방향·초안 형식). 전체 588 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:25:28 +09:00
parent c9ce36138f
commit 925d91a4e5
7 changed files with 261 additions and 7 deletions
+5
View File
@@ -80,6 +80,11 @@ export async function createLessonCard(situation?: string): Promise<void> {
while (fs.existsSync(filePath)) { filePath = path.join(dir, `${today}-${lessonSlug(title)}-${n++}.md`); }
try {
fs.writeFileSync(filePath, lessonTemplate(title, today, situation), 'utf8');
// A-MEM 레슨 네트워크 — 비슷한 과거 교훈과 상호 링크 (실패 무해).
try {
const { linkRelatedLessons } = await import('../intelligence/lessonNetwork');
linkRelatedLessons(brainDir, filePath);
} catch { /* 링크 실패가 생성을 막지 않음 */ }
} catch (e: any) {
vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`);
return;
+73 -4
View File
@@ -24,7 +24,9 @@ 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 RECONCILE_DIR_REL = path.join('.astra', 'growth', 'reconcile');
const MAX_COMPARES_PER_RUN = 5;
const MAX_RECONCILE_PER_RUN = 3;
const NEIGHBOR_K = 2;
/** 스캔 제외 — 소화 노트·레슨·자동 생성 인벤토리는 지식 본문이 아니다. */
@@ -51,6 +53,41 @@ function saveLastScanMs(brainPath: string, ms: number): void {
} catch { /* 상태 저장 실패 — 다음 런이 더 넓게 스캔할 뿐 */ }
}
// ── 지식 재구성 — 충돌을 감지에서 끝내지 않고 통합 초안까지 (적용은 사람) ──────
const RECONCILE_SYSTEM = [
'너는 지식 통합 편집자다. 같은 주제에 대해 모순되는 두 위키 문서를 받아, 모순을 해소한 *통합 초안*을 작성한다.',
'규칙:',
'1. [신뢰 권고]가 우선하는 쪽의 사실을 기준으로 삼되, 다른 쪽의 유효한(모순되지 않는) 정보는 보존하라.',
'2. 어느 쪽이 맞는지 문서만으로 판단 불가능한 사실은 양쪽을 병기하고 "(확인 필요: A는 X, B는 Y)" 로 표시하라.',
'3. 원문에 없는 내용을 지어내지 마라. 출처 문서 제목을 [제목] 으로 표기하라.',
'4. 마크다운 본문만 출력 (frontmatter·해설 없이).',
].join('\n');
/** 통합 초안 파일 본문 (순수 — 테스트 가능). 적용은 사람이 검토 후 직접. */
export function reconcileDraftMarkdown(f: ConflictFinding, body: string, nowIso: string): string {
return [
'---',
'type: reconcile-draft',
`created_at: ${nowIso}`,
`conflict_new: "${f.newDoc.replace(/"/g, "'")}"`,
`conflict_existing: "${f.existingDoc.replace(/"/g, "'")}"`,
'status: pending-review',
'---',
'',
`# 통합 초안: ${path.basename(f.newDoc, '.md')}${path.basename(f.existingDoc, '.md')}`,
'',
`> ⚔️ 모순: ${f.summary}`,
`> ${f.recommend}`,
'> ',
'> ✋ **이 초안은 자동 반영되지 않습니다.** 검토 후 승인하면 아래 본문을 대상 문서에 직접 반영하고,',
'> 다른 쪽 문서를 수정/삭제하세요. 거부하면 이 파일을 삭제하면 됩니다.',
'',
body.trim(),
'',
].join('\n');
}
const COMPARE_SYSTEM = [
'너는 지식 정합성 검사기다. [신규 문서]와 [기존 문서]가 같은 주제에 대해 *모순되는 사실*을 말하는지 판정하라.',
'모순 = 같은 대상에 대해 양립 불가능한 수치·날짜·결론·정의. 관점 차이·세부 수준 차이·다른 주제는 모순이 아니다.',
@@ -63,6 +100,8 @@ export interface ConflictFinding {
summary: string;
/** 신뢰도 비교 기반 우선 권고 (자동 삭제는 하지 않음 — 결정은 사람). */
recommend: string;
/** 생성된 통합 초안의 두뇌 상대 경로 (생성 실패/상한 초과 시 없음). */
draftPath?: string;
}
// ── 신뢰도 비교 — frontmatter(source_trust_level·confidence_score) + 최신성 ──
@@ -132,6 +171,7 @@ export async function runConflictScanOnce(): Promise<string> {
const orchestrator = new RetrievalOrchestrator();
const findings: ConflictFinding[] = [];
let compared = 0;
let reconciled = 0;
for (const t of targets) {
try {
@@ -158,12 +198,41 @@ export async function runConflictScanOnce(): Promise<string> {
if (parsed?.conflict === true && String(parsed?.summary || '').trim()) {
let existingMtime = 0;
try { existingMtime = fs.statSync(n.filePath).mtimeMs; } catch { /* 0 유지 */ }
findings.push({
const finding: ConflictFinding = {
newDoc: rel,
existingDoc: n.relativePath,
summary: String(parsed.summary).slice(0, 200),
recommend: buildTrustRecommendation(parseDocTrust(content, t.m), parseDocTrust(existing, existingMtime)),
});
};
findings.push(finding);
// [지식 재구성] 감지에서 끝내지 않고 통합 초안 생성 → 사람 승인 대기.
// 양쪽 본문이 지금 스코프에 있을 때 바로 생성 (런당 상한 — LLM 시간 보호).
if (reconciled < MAX_RECONCILE_PER_RUN) {
try {
const draftBody = await simpleChatCompletion(
RECONCILE_SYSTEM,
[
`[모순] ${finding.summary}`,
`[신뢰 권고] ${finding.recommend}`,
`[문서 A — 신규: ${path.basename(rel, '.md')}]\n${content.slice(0, 4000)}`,
`[문서 B — 기존: ${path.basename(n.relativePath, '.md')}]\n${existing.slice(0, 4000)}`,
].join('\n\n'),
{ baseUrl: config.ollamaUrl, model: config.defaultModel, temperature: 0.2, maxTokens: 1600, timeoutMs: 180000 },
);
if (draftBody.trim()) {
const dir = path.join(brainPath, RECONCILE_DIR_REL);
fs.mkdirSync(dir, { recursive: true });
const slug = path.basename(rel, '.md').replace(/[^a-zA-Z0-9가-힣_-]+/g, '-').slice(0, 50);
const draftFile = path.join(dir, `${new Date().toISOString().slice(0, 10)}-${slug}.md`);
fs.writeFileSync(draftFile, reconcileDraftMarkdown(finding, draftBody, new Date().toISOString()), 'utf8');
finding.draftPath = path.relative(brainPath, draftFile);
reconciled++;
}
} catch (e: any) {
logError('통합 초안 생성 실패 (감지 결과는 유지).', { error: e?.message ?? String(e) });
}
}
}
}
} catch (e: any) {
@@ -177,7 +246,7 @@ export async function runConflictScanOnce(): Promise<string> {
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 → ${f.recommend}\n → 어느 쪽을 유지할지 결정 후 다른 쪽을 수정/삭제하세요 (시스템은 덮어쓰지 않음).`),
...findings.map(f => `- ⚔️ 신규 **${f.newDoc}** ↔ 기존 **${f.existingDoc}**: ${f.summary}\n → ${f.recommend}${f.draftPath ? `\n → 📝 통합 초안: \`${f.draftPath}\` (검토 후 승인 시 직접 반영)` : ''}\n → 어느 쪽을 유지할지 결정 후 다른 쪽을 수정/삭제하세요 (시스템은 덮어쓰지 않음).`),
].join('\n');
try {
fs.mkdirSync(path.dirname(reportFile), { recursive: true });
@@ -188,7 +257,7 @@ export async function runConflictScanOnce(): Promise<string> {
`⚔️ 지식 충돌 ${findings.length}건 감지 — 예: "${findings[0].newDoc}" ↔ "${findings[0].existingDoc}". conflict-report.md 에서 확인하세요.`,
);
}
const summary = `신규 ${targets.length}건 스캔 · 비교 ${compared}회 · 충돌 ${findings.length}`;
const summary = `신규 ${targets.length}건 스캔 · 비교 ${compared}회 · 충돌 ${findings.length}${reconciled ? ` · 통합 초안 ${reconciled}건 (reconcile/)` : ''}`;
logInfo('지식 충돌 스캔 완료.', { summary });
return summary;
}
+3
View File
@@ -18,6 +18,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { simpleChatCompletion } from './llmCall';
import { loadQueue, saveQueue } from './learningQueue';
import { linkRelatedLessons } from './lessonNetwork';
// ── 타입/상수 ────────────────────────────────────────────────────────────────
@@ -196,6 +197,8 @@ export async function captureCorrection(opts: {
const slug = title.toLowerCase().replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'correction';
const file = path.join(dir, `${ymd}-correction-${slug}.md`);
fs.writeFileSync(file, correctionLessonMarkdown(c, ymd), 'utf8');
// A-MEM 레슨 네트워크 — 비슷한 과거 교훈과 상호 링크 (LLM 호출 없음, 실패 무해).
try { linkRelatedLessons(opts.brainPath, file); } catch { /* 링크 실패가 캡처를 막지 않음 */ }
return file;
} catch {
return null;
+93
View File
@@ -0,0 +1,93 @@
/**
* 레슨 네트워크 — A-MEM(NeurIPS 2025, Zettelkasten 원리)의 ASTRA 이식.
*
* 문제: lessons/ 가 고립된 카드 모음 — 새 레슨이 과거의 비슷한 교훈과 연결되지
* 않아 "같은 종류의 실수"라는 패턴이 파일 수준에서 보이지 않는다.
*
* A-MEM 의 두 메커니즘을 파일 기반으로 구현:
* 1. 동적 링킹: 새 레슨 저장 시 기존 레슨과 유사도(토큰 자카드)를 계산해
* 상위 K 개를 "## 관련 레슨" 섹션으로 연결.
* 2. 역방향 갱신(memory evolution): 연결된 기존 레슨에도 새 레슨으로의
* 백링크를 추가 — 네트워크가 양방향으로 자란다.
*
* 레슨 수가 적으므로(수백 이하) 임베딩 없이 토큰 유사도로 충분 — LLM 호출 0,
* 캡처 경로의 지연 비용 없음. 링크는 [[제목]] 위키링크 — 두뇌 RAG 가 그대로 검색.
*/
import * as fs from 'fs';
import * as path from 'path';
import { tokenize } from '../retrieval/scoring';
const RELATED_HEADING = '## 관련 레슨';
const MAX_LINKS = 3;
const MIN_SIMILARITY = 0.12;
const MAX_LESSONS_SCANNED = 300;
/** 토큰 자카드 유사도 (순수). */
export function lessonSimilarity(tokensA: string[], tokensB: string[]): number {
if (!tokensA.length || !tokensB.length) return 0;
const a = new Set(tokensA), b = new Set(tokensB);
let inter = 0;
for (const t of a) if (b.has(t)) inter++;
return inter / (a.size + b.size - inter);
}
/**
* 본문에 "## 관련 레슨" 링크를 추가 (섹션 없으면 생성, 있으면 항목 append).
* 이미 같은 링크가 있으면 그대로 반환 (멱등 — 재실행 안전).
*/
export function addRelatedLink(content: string, linkTitle: string): string {
const link = `- [[${linkTitle}]]`;
if (content.includes(link)) return content;
const idx = content.indexOf(RELATED_HEADING);
if (idx < 0) {
return content.trimEnd() + `\n\n${RELATED_HEADING}\n${link}\n`;
}
// 섹션 끝(다음 헤딩 또는 EOF) 직전에 삽입.
const after = content.slice(idx + RELATED_HEADING.length);
const nextHeading = after.search(/\n#{1,6}\s/);
const insertAt = idx + RELATED_HEADING.length + (nextHeading >= 0 ? nextHeading : after.length);
return content.slice(0, insertAt).trimEnd() + `\n${link}` + content.slice(insertAt);
}
/**
* 새 레슨을 기존 레슨 네트워크에 연결한다 — 상위 K 유사 레슨과 상호 링크.
* 반환: 연결된 레슨 수. 실패는 0 (레슨 저장 자체를 막지 않음).
*/
export function linkRelatedLessons(brainPath: string, newLessonFile: string): number {
try {
const lessonsDir = path.join(brainPath, 'lessons');
if (!fs.existsSync(lessonsDir) || !fs.existsSync(newLessonFile)) return 0;
const newContent = fs.readFileSync(newLessonFile, 'utf8');
const newTitle = path.basename(newLessonFile, '.md');
const newTokens = tokenize(newContent.slice(0, 3000));
const candidates = fs.readdirSync(lessonsDir)
.filter(f => f.endsWith('.md') && path.join(lessonsDir, f) !== newLessonFile)
.slice(0, MAX_LESSONS_SCANNED)
.map(f => {
const fp = path.join(lessonsDir, f);
try {
const c = fs.readFileSync(fp, 'utf8');
return { fp, title: path.basename(f, '.md'), content: c, sim: lessonSimilarity(newTokens, tokenize(c.slice(0, 3000))) };
} catch { return null; }
})
.filter((x): x is NonNullable<typeof x> => !!x && x.sim >= MIN_SIMILARITY)
.sort((a, b) => b.sim - a.sim)
.slice(0, MAX_LINKS);
if (candidates.length === 0) return 0;
// 새 레슨 → 관련 레슨 링크.
let updatedNew = newContent;
for (const c of candidates) updatedNew = addRelatedLink(updatedNew, c.title);
fs.writeFileSync(newLessonFile, updatedNew, 'utf8');
// 역방향 갱신 — 기존 레슨에 백링크 (A-MEM memory evolution).
for (const c of candidates) {
try { fs.writeFileSync(c.fp, addRelatedLink(c.content, newTitle), 'utf8'); } catch { /* 한쪽 실패 무시 */ }
}
return candidates.length;
} catch {
return 0;
}
}