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,101 @@
|
||||
/**
|
||||
* Sleep-time 사전 소화 — 순수 로직 테스트 (대상 선정·노후화 판정·노트 형식).
|
||||
* LLM 호출(runSleepDigestOnce)은 제외 — 통합 검증은 수동 명령으로.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
selectDigestTargets, folderSlug, isDigestFresh, digestMarkdown, DIGEST_DIR,
|
||||
type DigestTarget,
|
||||
} from '../src/features/growth/sleepDigest';
|
||||
|
||||
const tmpBrain = () => fs.mkdtempSync(path.join(os.tmpdir(), 'astra-digest-'));
|
||||
|
||||
function mkFile(brain: string, rel: string, ageDays: number): string {
|
||||
const abs = path.join(brain, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, `# ${path.basename(rel)}\n내용`, 'utf8');
|
||||
const t = new Date(Date.now() - ageDays * 86_400_000);
|
||||
fs.utimesSync(abs, t, t);
|
||||
return abs;
|
||||
}
|
||||
|
||||
describe('selectDigestTargets', () => {
|
||||
test('최근 변경 파일이 많은 폴더 우선, 오래된 파일 제외', () => {
|
||||
const brain = tmpBrain();
|
||||
const files = [
|
||||
mkFile(brain, 'Topics_Rag/a.md', 1),
|
||||
mkFile(brain, 'Topics_Rag/b.md', 2),
|
||||
mkFile(brain, 'Topics_Rag/c.md', 3),
|
||||
mkFile(brain, 'Coding/Python/x.md', 1),
|
||||
mkFile(brain, 'Coding/Python/y.md', 2),
|
||||
mkFile(brain, 'Old_Folder/z.md', 30), // 윈도우 밖
|
||||
];
|
||||
const targets = selectDigestTargets(brain, files, Date.now(), 7);
|
||||
expect(targets[0].folder).toBe('Topics_Rag');
|
||||
expect(targets[0].files).toHaveLength(3);
|
||||
expect(targets[1].folder).toBe('Coding/Python');
|
||||
expect(targets.find(t => t.folder === 'Old_Folder')).toBeUndefined();
|
||||
});
|
||||
test('Digests 폴더 자신은 재소화하지 않음 + 루트 직속은 빈 키', () => {
|
||||
const brain = tmpBrain();
|
||||
const files = [
|
||||
mkFile(brain, `${DIGEST_DIR}/old-digest.md`, 1),
|
||||
mkFile(brain, 'root-note.md', 1),
|
||||
];
|
||||
const targets = selectDigestTargets(brain, files, Date.now(), 7);
|
||||
expect(targets.find(t => t.folder.includes(DIGEST_DIR))).toBeUndefined();
|
||||
expect(targets.find(t => t.folder === '')).toBeDefined();
|
||||
});
|
||||
test('파일 상한 8개', () => {
|
||||
const brain = tmpBrain();
|
||||
const files = Array.from({ length: 12 }, (_, i) => mkFile(brain, `Big/f${i}.md`, 1));
|
||||
const targets = selectDigestTargets(brain, files, Date.now(), 7);
|
||||
expect(targets[0].files.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folderSlug', () => {
|
||||
test('경로 구분자·특수문자 정리', () => {
|
||||
expect(folderSlug('Coding/Python')).toBe('Coding--Python');
|
||||
expect(folderSlug('')).toBe('root');
|
||||
expect(folderSlug('AI & ML!')).toBe('AI-ML-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDigestFresh — 노후화 판정', () => {
|
||||
const target = (over: Partial<DigestTarget> = {}): DigestTarget => ({
|
||||
folder: 'X', slug: 'X', files: [], newestMtimeMs: Date.now(), ...over,
|
||||
});
|
||||
test('노트 없음 → 재생성 필요', () => {
|
||||
expect(isDigestFresh(path.join(tmpBrain(), 'no.md'), Date.now())).toBe(false);
|
||||
});
|
||||
test('generated_at ≥ 소스 mtime → fresh', () => {
|
||||
const brain = tmpBrain();
|
||||
const f = path.join(brain, 'd.md');
|
||||
const t = target();
|
||||
fs.writeFileSync(f, digestMarkdown(t, '본문', ['s1'], new Date(t.newestMtimeMs + 1000).toISOString()), 'utf8');
|
||||
expect(isDigestFresh(f, t.newestMtimeMs)).toBe(true);
|
||||
});
|
||||
test('소스가 노트보다 최신 → 재생성 필요', () => {
|
||||
const brain = tmpBrain();
|
||||
const f = path.join(brain, 'd.md');
|
||||
fs.writeFileSync(f, digestMarkdown(target(), '본문', ['s1'], new Date(Date.now() - 86_400_000).toISOString()), 'utf8');
|
||||
expect(isDigestFresh(f, Date.now())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('digestMarkdown', () => {
|
||||
test('frontmatter(type: digest, sources) + 원문 우선 고지', () => {
|
||||
const md = digestMarkdown(
|
||||
{ folder: 'Topics_Rag', slug: 'Topics_Rag', files: [], newestMtimeMs: 0 },
|
||||
'## 예상 질문과 답\n- Q…', ['문서 "A"', 'B'], '2026-06-12T03:00:00Z',
|
||||
);
|
||||
expect(md).toMatch(/^---\ntype: digest/);
|
||||
expect(md).toContain('generated_at: 2026-06-12T03:00:00Z');
|
||||
expect(md).toContain("문서 'A'"); // 따옴표 이스케이프
|
||||
expect(md).toContain('원문이 항상 우선');
|
||||
expect(md).toContain('# 소화 노트: Topics_Rag');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user