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:
@@ -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