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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astra",
|
||||
"version": "2.2.223",
|
||||
"version": "2.2.224",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astra",
|
||||
"version": "2.2.223",
|
||||
"version": "2.2.224",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
|
||||
+15
-1
@@ -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.223",
|
||||
"version": "2.2.224",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -74,6 +74,10 @@
|
||||
"command": "g1nation.embeddings.backfill",
|
||||
"title": "Astra: 두뇌 임베딩 전체 색인"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.sleepDigest.runNow",
|
||||
"title": "Astra: 지식 사전 소화 지금 실행 (Sleep-time Digest)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.exportChat",
|
||||
"title": "Astra: Export Chat as Markdown"
|
||||
@@ -318,6 +322,16 @@
|
||||
"default": "09:30",
|
||||
"markdownDescription": "데일리 브리핑 발송 시각 (KST, `HH:MM`). 기본 `09:30`."
|
||||
},
|
||||
"g1nation.sleepDigest.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "**Sleep-time 지식 사전 소화** — 매일 지정 시각(유휴 시간)에 최근 7일 내 변경된 두뇌 지식을 폴더별 '소화 노트'(`<두뇌>/Digests/`)로 변환합니다 (런당 최대 5건, 소스 미변경 시 skip). 소화 노트는 예상 질의 Q&A·핵심 사실·문서 간 연결을 담아 RAG 검색 품질을 높입니다. 원문이 항상 우선 — 노트는 삭제해도 안전 (자동 재생성). 근거: sleep-time compute (arXiv 2504.13171)."
|
||||
},
|
||||
"g1nation.sleepDigest.time": {
|
||||
"type": "string",
|
||||
"default": "03:00",
|
||||
"markdownDescription": "사전 소화 실행 시각 (KST, `HH:MM`). 기본 `03:00` — VS Code 가 켜져 있는 유휴 시간 권장."
|
||||
},
|
||||
"g1nation.growthCycle.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
|
||||
@@ -43,6 +43,7 @@ import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
|
||||
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
|
||||
import { ensureEmbeddingConfigured } from './extension/embeddingBootstrap';
|
||||
import { startGrowthCycleWatcher, runGrowthCycleOnce } from './features/growth/growthCycleWatcher';
|
||||
import { startSleepDigestWatcher, runSleepDigestOnce } from './features/growth/sleepDigest';
|
||||
import { registerProviderCommands } from './extension/providerCommands';
|
||||
import { registerScaffoldCommand } from './extension/scaffoldCommand';
|
||||
import { registerLessonCommands } from './extension/lessonCommands';
|
||||
@@ -332,6 +333,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
vscode.window.showInformationMessage(`성장 사이클 완료 — ${summary}`);
|
||||
}));
|
||||
|
||||
// Sleep-time 지식 사전 소화 — 유휴 시간(기본 03:00 KST)에 최근 변경 지식을
|
||||
// learned-context 소화 노트(<brain>/Digests/)로 변환. 응답 지연을 유휴 시간으로 이동.
|
||||
context.subscriptions.push(startSleepDigestWatcher());
|
||||
context.subscriptions.push(vscode.commands.registerCommand('g1nation.sleepDigest.runNow', async () => {
|
||||
vscode.window.showInformationMessage('지식 사전 소화 실행 중… (LLM 호출, 수 분 소요 가능)');
|
||||
const summary = await runSleepDigestOnce();
|
||||
vscode.window.showInformationMessage(`사전 소화 완료 — ${summary}`);
|
||||
}));
|
||||
|
||||
// 7. Auto-open all three Astra webviews as tabs in editor column 3.
|
||||
// The sidebar/activity-bar entry point was removed in 2.81 — all three views
|
||||
// (Chat, Approvals, Settings) now stack as tabs in the third editor column.
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
}
|
||||
@@ -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