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
+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;
}