diff --git a/package-lock.json b/package-lock.json index a5a98a2..c96f940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.227", + "version": "2.2.228", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.227", + "version": "2.2.228", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index df2405e..e46ac22 100644 --- a/package.json +++ b/package.json @@ -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.227", + "version": "2.2.228", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/extension/lessons.ts b/src/extension/lessons.ts index 267c493..47a18d7 100644 --- a/src/extension/lessons.ts +++ b/src/extension/lessons.ts @@ -80,6 +80,11 @@ export async function createLessonCard(situation?: string): Promise { 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; diff --git a/src/features/growth/conflictScan.ts b/src/features/growth/conflictScan.ts index c41aa8f..44d1d31 100644 --- a/src/features/growth/conflictScan.ts +++ b/src/features/growth/conflictScan.ts @@ -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 { 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 { 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 { 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 { `⚔️ 지식 충돌 ${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; } diff --git a/src/intelligence/correctionLoop.ts b/src/intelligence/correctionLoop.ts index 15b86dc..3566155 100644 --- a/src/intelligence/correctionLoop.ts +++ b/src/intelligence/correctionLoop.ts @@ -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; diff --git a/src/intelligence/lessonNetwork.ts b/src/intelligence/lessonNetwork.ts new file mode 100644 index 0000000..fc01509 --- /dev/null +++ b/src/intelligence/lessonNetwork.ts @@ -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 => !!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; + } +} diff --git a/tests/lessonNetwork.test.ts b/tests/lessonNetwork.test.ts new file mode 100644 index 0000000..8298c9f --- /dev/null +++ b/tests/lessonNetwork.test.ts @@ -0,0 +1,84 @@ +/** + * 레슨 네트워크(A-MEM 이식) + 통합 초안 형식 — 순수 로직 테스트. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { lessonSimilarity, addRelatedLink, linkRelatedLessons } from '../src/intelligence/lessonNetwork'; +import { reconcileDraftMarkdown, type ConflictFinding } from '../src/features/growth/conflictScan'; +import { tokenize } from '../src/retrieval/scoring'; + +const tmpBrain = () => fs.mkdtempSync(path.join(os.tmpdir(), 'astra-net-')); + +function mkLesson(brain: string, name: string, body: string): string { + const dir = path.join(brain, 'lessons'); + fs.mkdirSync(dir, { recursive: true }); + const fp = path.join(dir, `${name}.md`); + fs.writeFileSync(fp, body, 'utf8'); + return fp; +} + +describe('lessonSimilarity', () => { + test('겹치는 토큰 비율 — 동일 주제 > 무관 주제', () => { + const a = tokenize('회의 날짜를 잘못 기억해 캘린더 등록 오류 발생'); + const b = tokenize('회의 일정 등록 시 날짜 확인 누락으로 오류'); + const c = tokenize('파이썬 가상환경 의존성 충돌 해결 방법'); + expect(lessonSimilarity(a, b)).toBeGreaterThan(lessonSimilarity(a, c)); + expect(lessonSimilarity(a, a)).toBe(1); + expect(lessonSimilarity([], a)).toBe(0); + }); +}); + +describe('addRelatedLink', () => { + test('섹션 없으면 생성, 있으면 append, 중복은 멱등', () => { + let c = '# Lesson: X\n\n## Fix\n내용\n'; + c = addRelatedLink(c, '레슨A'); + expect(c).toContain('## 관련 레슨\n- [[레슨A]]'); + c = addRelatedLink(c, '레슨B'); + expect(c).toContain('- [[레슨A]]\n- [[레슨B]]'); + const again = addRelatedLink(c, '레슨A'); + expect(again).toBe(c); // 멱등 + }); + test('관련 레슨 섹션 뒤에 다른 헤딩이 있어도 섹션 안에 삽입', () => { + const c = '# L\n\n## 관련 레슨\n- [[기존]]\n\n## Applies To\n- 태그\n'; + const out = addRelatedLink(c, '신규'); + expect(out.indexOf('- [[신규]]')).toBeLessThan(out.indexOf('## Applies To')); + }); +}); + +describe('linkRelatedLessons — 상호 링크 + 역방향 갱신', () => { + test('유사 레슨과 양방향 링크 생성', () => { + const brain = tmpBrain(); + const oldFp = mkLesson(brain, 'old-meeting-date', '# Lesson: 회의 날짜 오류\n\n## Mistake / Risk\n회의 날짜를 잘못 기억해 캘린더 등록 오류 발생\n'); + mkLesson(brain, 'unrelated-python', '# Lesson: 파이썬 환경\n\n## Mistake / Risk\n파이썬 가상환경 의존성 충돌 npm 빌드 도구 체인 별개 주제\n'); + const newFp = mkLesson(brain, 'new-meeting-date', '# Lesson: 회의 일정 재발\n\n## Mistake / Risk\n회의 일정 등록 시 날짜 확인 누락 캘린더 오류\n'); + + const linked = linkRelatedLessons(brain, newFp); + expect(linked).toBeGreaterThanOrEqual(1); + expect(fs.readFileSync(newFp, 'utf8')).toContain('[[old-meeting-date]]'); + expect(fs.readFileSync(oldFp, 'utf8')).toContain('[[new-meeting-date]]'); // 역방향 + }); + test('유사 레슨 없으면 0 (파일 미변경)', () => { + const brain = tmpBrain(); + mkLesson(brain, 'a', '# Lesson: 완전히 다른 알파 베타 감마 주제\n'); + const newFp = mkLesson(brain, 'b', '# Lesson: 전혀 무관한 델타 입실론 제타\n'); + const before = fs.readFileSync(newFp, 'utf8'); + expect(linkRelatedLessons(brain, newFp)).toBe(0); + expect(fs.readFileSync(newFp, 'utf8')).toBe(before); + }); +}); + +describe('reconcileDraftMarkdown', () => { + test('frontmatter + 비자동반영 고지 + 본문', () => { + const f: ConflictFinding = { + newDoc: 'AI_and_ML/신규.md', existingDoc: 'AI_and_ML/기존.md', + summary: '버전 수치 모순', recommend: '권고: **신규 우선** (S·1.0 vs A·0.8)', + }; + const md = reconcileDraftMarkdown(f, '## 통합 내용\n…', '2026-06-12T00:00:00Z'); + expect(md).toMatch(/^---\ntype: reconcile-draft/); + expect(md).toContain('status: pending-review'); + expect(md).toContain('자동 반영되지 않습니다'); + expect(md).toContain('버전 수치 모순'); + expect(md).toContain('신규 우선'); + }); +});