feat(growth): Sleep-time 지식 사전 소화 — 응답 지연을 유휴 시간으로 이동 (v2.2.224)
deep research(3표 검증) 1순위 적용: sleep-time compute (arXiv 2504.13171). 유휴 시간에 로컬 LLM이 두뇌의 raw context를 learned context로 미리 소화해, 응답 시점 RAG가 고밀도 소화 노트를 검색하게 한다 — 로컬 LLM의 최대 약점 (느린 추론)의 비용을 사용자 응대 시점에서 유휴 시간으로 구조적으로 이동. - sleepDigest.ts: 매일 03:00 KST(설정 가능) 최근 7일 변경 파일이 많은 폴더 순으로 소화 노트 생성 (<두뇌>/Digests/<슬러그>.md, 런당 ≤5건). 노트 = 예상 질의 Q&A + 핵심 사실 + 문서 간 연결 (출처 제목 인용 강제, "원문에 없는 내용 지어내지 마라" — 환각 방지 동일 원칙). - 노후화 자동 감지: 소스 mtime > generated_at 이면 재생성, 아니면 skip (steady-state 비용 0). 노트는 삭제해도 안전 (자동 재생성). - 승인 게이트 불요 근거: 외부 지식 유입이 아니라 기존 두뇌의 재구성. 원문 우선 원칙을 노트 머리에 명기. - 수동 명령 "Astra: 지식 사전 소화 지금 실행" + sleepDigest.enabled/time 설정. - 실 LLM(gemma-4-26b)+실 위키 3문서로 프롬프트 품질 검증 완료 (출처 인용· 무환각 확인). 테스트 8건 추가. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Sleep-time 지식 사전 소화 (Sleep-time Compute, arXiv 2504.13171 의 ASTRA 이식).
|
||||
*
|
||||
* 아이디어: 로컬 LLM 의 최대 약점(느린 추론)을 응답 시점에서 *유휴 시간*으로 옮긴다.
|
||||
* 유휴 시간에 두뇌의 "raw context"(위키 원문 더미)를 "learned context"(예상 질의별
|
||||
* 압축 소화 노트)로 변환해 두면, 응답 시점의 RAG 가 이미 소화된 고밀도 노트를
|
||||
* 검색해 적은 토큰으로 더 정확히 답한다.
|
||||
*
|
||||
* 설계 (ASTRA 제약 반영):
|
||||
* - 저장: <brain>/Digests/<slug>.md — 검색 가능한 지식 폴더. type: digest frontmatter.
|
||||
* 사람이 열람·수정·삭제 가능 (파일이 UI). 원문이 항상 우선 — 소화 노트는 요약 캐시.
|
||||
* - 대상 선정: 최근 N일 내 수정 파일이 많은 폴더 순 (최근 추가된 지식이 가장 질의될
|
||||
* 확률이 높다는 가설 — sleep-time 효과는 질의 예측 가능성에 비례).
|
||||
* - 노후화: 소스 파일 mtime > 소화 노트 generated_at 이면 재생성. 아니면 skip
|
||||
* (steady-state 비용 0).
|
||||
* - 승인 불요: 외부 지식 유입이 아니라 기존 두뇌의 재구성이므로 Permission Based
|
||||
* Learning 의 승인 게이트 대상이 아니다. 단 전부 파일로 투명하게.
|
||||
* - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
import { findBrainFiles, getActiveBrainProfile, logError, logInfo } from '../../utils';
|
||||
import { simpleChatCompletion } from '../../intelligence/llmCall';
|
||||
|
||||
export const DIGEST_DIR = 'Digests';
|
||||
const MAX_PER_RUN = 5;
|
||||
const RECENT_WINDOW_DAYS = 7;
|
||||
const MAX_FILES_PER_TARGET = 8;
|
||||
const MAX_INPUT_CHARS = 9000;
|
||||
|
||||
let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
let _lastFiredYmd = '';
|
||||
|
||||
// ── 대상 선정 (순수 로직 — 테스트 대상) ─────────────────────────────────────
|
||||
|
||||
export interface DigestTarget {
|
||||
/** 폴더 키 (brain 상대 경로, '' = 루트 직속). */
|
||||
folder: string;
|
||||
/** 파일명 슬러그. */
|
||||
slug: string;
|
||||
/** 최근 수정 파일 절대 경로 (mtime 내림차순, 상한 적용). */
|
||||
files: string[];
|
||||
/** 가장 최신 소스 mtime — 노후화 판정용. */
|
||||
newestMtimeMs: number;
|
||||
}
|
||||
|
||||
export function folderSlug(folder: string): string {
|
||||
const base = (folder || 'root').replace(/[\\/]+/g, '--').replace(/[^a-zA-Z0-9가-힣_.-]+/g, '-');
|
||||
return base.slice(0, 80) || 'root';
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 windowDays 내 수정된 파일을 폴더(두뇌 1-2단계 경로)별로 묶어,
|
||||
* 수정 파일 수가 많은 순으로 소화 대상을 고른다. Digests/ 자신은 제외.
|
||||
*/
|
||||
export function selectDigestTargets(
|
||||
brainPath: string,
|
||||
allFiles: string[],
|
||||
nowMs: number,
|
||||
windowDays = RECENT_WINDOW_DAYS,
|
||||
): DigestTarget[] {
|
||||
const cutoff = nowMs - windowDays * 86_400_000;
|
||||
const byFolder = new Map<string, Array<{ file: string; mtimeMs: number }>>();
|
||||
for (const f of allFiles) {
|
||||
let mtimeMs = 0;
|
||||
try { mtimeMs = fs.statSync(f).mtimeMs; } catch { continue; }
|
||||
if (mtimeMs < cutoff) continue;
|
||||
const rel = path.relative(brainPath, f);
|
||||
const parts = rel.split(/[\\/]/);
|
||||
if (parts[0] === DIGEST_DIR) continue; // 소화 노트 자신은 재소화하지 않음
|
||||
// 폴더 키: 최대 2단계 (예: "Topics_Rag", "Coding/Python"). 루트 직속 파일은 ''.
|
||||
const folder = parts.length > 2 ? parts.slice(0, 2).join('/') : parts.length === 2 ? parts[0] : '';
|
||||
const arr = byFolder.get(folder) || [];
|
||||
arr.push({ file: f, mtimeMs });
|
||||
byFolder.set(folder, arr);
|
||||
}
|
||||
return Array.from(byFolder.entries())
|
||||
.map(([folder, items]) => {
|
||||
items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return {
|
||||
folder,
|
||||
slug: folderSlug(folder),
|
||||
files: items.slice(0, MAX_FILES_PER_TARGET).map(i => i.file),
|
||||
newestMtimeMs: items[0].mtimeMs,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.files.length - a.files.length || b.newestMtimeMs - a.newestMtimeMs);
|
||||
}
|
||||
|
||||
/** 소화 노트가 최신인지 — generated_at ≥ 소스 최신 mtime 이면 skip. */
|
||||
export function isDigestFresh(digestFile: string, newestSourceMtimeMs: number): boolean {
|
||||
try {
|
||||
if (!fs.existsSync(digestFile)) return false;
|
||||
const head = fs.readFileSync(digestFile, 'utf8').slice(0, 600);
|
||||
const m = head.match(/^generated_at:\s*["']?([^"'\n]+)["']?\s*$/m);
|
||||
if (!m) return false;
|
||||
const t = Date.parse(m[1].trim());
|
||||
return Number.isFinite(t) && t >= newestSourceMtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 소화 노트 생성 ───────────────────────────────────────────────────────────
|
||||
|
||||
const DIGEST_SYSTEM = [
|
||||
'너는 지식 소화기다. 주어진 위키 문서들을 읽고, 나중에 사용자가 물어볼 법한 질문에 즉시 답할 수 있는 "소화 노트"를 만든다.',
|
||||
'형식 (마크다운):',
|
||||
'## 예상 질문과 답',
|
||||
'- **Q: <예상 질문>** — A: <원문 근거 기반 압축 답변. 출처 문서 제목을 [제목] 으로 표기>',
|
||||
' (질문 4~7개 — 문서들이 실제로 답할 수 있는 것만)',
|
||||
'## 핵심 사실',
|
||||
'- <문서들에서 가장 중요한 사실·수치·결론. 출처 [제목] 표기>',
|
||||
'## 문서 간 연결',
|
||||
'- <문서들 사이의 관계·공통 주제·모순(있다면)>',
|
||||
'규칙: 원문에 없는 내용을 지어내지 마라. 모순이 있으면 숨기지 말고 명시하라. 한국어로.',
|
||||
].join('\n');
|
||||
|
||||
function buildDigestInput(brainPath: string, target: DigestTarget): { input: string; sourceTitles: string[] } {
|
||||
const pieces: string[] = [];
|
||||
const titles: string[] = [];
|
||||
let used = 0;
|
||||
for (const f of target.files) {
|
||||
if (used >= MAX_INPUT_CHARS) break;
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
||||
const title = path.basename(f, '.md');
|
||||
titles.push(title);
|
||||
const budget = Math.min(MAX_INPUT_CHARS - used, Math.floor(MAX_INPUT_CHARS / Math.min(target.files.length, 4)));
|
||||
const body = content.slice(0, budget);
|
||||
pieces.push(`=== [${title}] ===\n${body}`);
|
||||
used += body.length;
|
||||
}
|
||||
return { input: pieces.join('\n\n'), sourceTitles: titles };
|
||||
}
|
||||
|
||||
export function digestMarkdown(target: DigestTarget, body: string, sourceTitles: string[], nowIso: string): string {
|
||||
const label = target.folder || '(두뇌 루트)';
|
||||
return [
|
||||
'---',
|
||||
'type: digest',
|
||||
`title: "소화 노트: ${label}"`,
|
||||
`generated_at: ${nowIso}`,
|
||||
`sources: [${sourceTitles.map(t => `"${t.replace(/"/g, "'")}"`).join(', ')}]`,
|
||||
'---',
|
||||
'',
|
||||
`# 소화 노트: ${label}`,
|
||||
'',
|
||||
`> ⚙️ 자동 생성 (sleep-time 사전 소화) — **원문이 항상 우선**입니다. 소스가 바뀌면 자동 재생성되며, 이 파일은 삭제해도 안전합니다.`,
|
||||
'',
|
||||
body.trim(),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ── 실행 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 사전 소화 1회 실행. 반환: 요약 문자열 (생성/스킵 건수). */
|
||||
export async function runSleepDigestOnce(): Promise<string> {
|
||||
const brain = getActiveBrainProfile();
|
||||
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) return '두뇌 폴더 없음 — skip';
|
||||
const config = getConfig();
|
||||
if (!config.defaultModel || !config.ollamaUrl) return 'LLM 미설정 — skip';
|
||||
|
||||
const brainPath = brain.localBrainPath;
|
||||
const digestDir = path.join(brainPath, DIGEST_DIR);
|
||||
const allFiles = findBrainFiles(brainPath);
|
||||
const targets = selectDigestTargets(brainPath, allFiles, Date.now());
|
||||
if (targets.length === 0) return '최근 변경 지식 없음 — 소화할 것 없음';
|
||||
|
||||
fs.mkdirSync(digestDir, { recursive: true });
|
||||
const readmePath = path.join(digestDir, 'README.md');
|
||||
if (!fs.existsSync(readmePath)) {
|
||||
fs.writeFileSync(readmePath, [
|
||||
'# Digests — sleep-time 사전 소화 노트',
|
||||
'',
|
||||
'유휴 시간에 ASTRA 가 최근 변경된 두뇌 지식을 미리 읽고 만든 "learned context" 입니다.',
|
||||
'검색(RAG)이 원문과 함께 이 노트를 찾아 응답 품질·속도를 높입니다.',
|
||||
'**원문이 항상 우선** — 소스 변경 시 자동 재생성되고, 삭제해도 다음 실행 때 다시 만들어집니다.',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
let made = 0, skipped = 0, failed = 0;
|
||||
for (const target of targets) {
|
||||
if (made >= MAX_PER_RUN) break;
|
||||
const digestFile = path.join(digestDir, `${target.slug}.md`);
|
||||
if (isDigestFresh(digestFile, target.newestMtimeMs)) { skipped++; continue; }
|
||||
try {
|
||||
const { input, sourceTitles } = buildDigestInput(brainPath, target);
|
||||
if (!input.trim()) { skipped++; continue; }
|
||||
const body = await simpleChatCompletion(
|
||||
DIGEST_SYSTEM,
|
||||
`[대상 폴더] ${target.folder || '(루트)'}\n\n${input}`,
|
||||
{ baseUrl: config.ollamaUrl, model: config.defaultModel, temperature: 0.2, maxTokens: 1400, timeoutMs: 180000 },
|
||||
);
|
||||
if (!body.trim()) { failed++; continue; }
|
||||
fs.writeFileSync(digestFile, digestMarkdown(target, body, sourceTitles, new Date().toISOString()), 'utf8');
|
||||
made++;
|
||||
logInfo('Sleep digest 생성.', { folder: target.folder, files: target.files.length });
|
||||
} catch (e: any) {
|
||||
failed++;
|
||||
logError('Sleep digest 실패 (다음 대상 계속).', { folder: target.folder, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
const summary = `소화 ${made}건 생성 · ${skipped}건 최신(스킵)${failed ? ` · ${failed}건 실패` : ''}`;
|
||||
logInfo('Sleep digest 사이클 완료.', { summary });
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ── 일일 스케줄러 (KST, stocksWatcher 패턴) ──────────────────────────────────
|
||||
|
||||
function nowInKst(): { hour: number; minute: number; ymd: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd: `${get('year')}-${get('month')}-${get('day')}` };
|
||||
}
|
||||
|
||||
function digestTime(): { hour: number; minute: number } {
|
||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00') || '03:00').trim();
|
||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
return m ? { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) } : { hour: 3, minute: 0 };
|
||||
}
|
||||
|
||||
function msUntilNextFire(): number {
|
||||
const { hour, minute, ymd } = nowInKst();
|
||||
const t = digestTime();
|
||||
const nowMin = hour * 60 + minute;
|
||||
const targetMin = t.hour * 60 + t.minute;
|
||||
if (targetMin > nowMin && _lastFiredYmd !== ymd) return (targetMin - nowMin) * 60_000;
|
||||
return ((24 * 60 - nowMin) + targetMin) * 60_000;
|
||||
}
|
||||
|
||||
function scheduleNext(): void {
|
||||
if (_disposed) return;
|
||||
const ms = msUntilNextFire();
|
||||
logInfo('Sleep digest 다음 실행 예약.', { inHours: (ms / 3_600_000).toFixed(1) });
|
||||
_timer = setTimeout(async () => {
|
||||
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('sleepDigest.enabled', true);
|
||||
if (enabled) {
|
||||
_lastFiredYmd = nowInKst().ymd;
|
||||
try { await runSleepDigestOnce(); } catch (e: any) {
|
||||
logError('Sleep digest 사이클 실패.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
scheduleNext();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/** VS Code 시작 시 호출 — disposable 반환. */
|
||||
export function startSleepDigestWatcher(): vscode.Disposable {
|
||||
_disposed = false;
|
||||
scheduleNext();
|
||||
return new vscode.Disposable(() => {
|
||||
_disposed = true;
|
||||
if (_timer) { clearTimeout(_timer); _timer = undefined; }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user