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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user