/** * Gap Detector / Need Engine / Knowledge Inventory / Learning Queue * (Self-Evolving OS Phase 3 — 성장 루프 코어) 테스트. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { detectGaps } from '../src/intelligence/gapDetector'; import { computeNeeds, knowledgeInventory, formatNeedsMarkdown, NEED_WEIGHTS } from '../src/intelligence/needEngine'; import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, QueueItem, } from '../src/intelligence/learningQueue'; import type { ReflectionRecord } from '../src/intelligence/reflectionStore'; function mkReflection(partial: Partial): ReflectionRecord { return { ts: '2026-06-11T10:00:00.000Z', taskId: 'meeting-minutes', taskLabel: '회의록', confidenceScore: 70, confidenceBand: 'medium', missing: [], escalated: false, criticIssues: null, promptPreview: 'p', retrieval: { chunkCount: 3, topScore: 0.6 }, weakGrounding: false, ...partial, }; } describe('detectGaps', () => { const okSignals = { chunkCount: 4, topScore: 0.7, conflictCount: 0, ambiguityDetected: false }; const noGrounding = { chunkCount: 0, topScore: 0, conflictCount: 0, ambiguityDetected: false }; it('누락 3개 이상 → high', () => { const g = detectGaps({ coverage: { ran: true, taskId: 'meeting-minutes', taskLabel: '회의록', covered: [], missing: ['참석자', '담당자', '기한'] }, signals: okSignals, taskId: 'meeting-minutes', }); expect(g.severity).toBe('high'); expect(g.summary).toContain('3개 누락'); }); it('근거 0건 단독 → low, 고영향 업무 + 누락이면 한 단계 상향', () => { const clean = detectGaps({ coverage: { ran: false, covered: [], missing: [] }, signals: noGrounding, taskId: null, }); expect(clean.severity).toBe('low'); expect(clean.weakGrounding).toBe(true); const worse = detectGaps({ coverage: { ran: true, taskId: 'meeting-minutes', taskLabel: '회의록', covered: [], missing: ['기한'] }, signals: noGrounding, taskId: 'meeting-minutes', }); expect(worse.severity).toBe('high'); // medium(누락1) + 고영향·근거없음 bump }); it('갭 없으면 none', () => { const g = detectGaps({ coverage: { ran: true, taskId: 'meeting-minutes', taskLabel: '회의록', covered: ['참석자'], missing: [] }, signals: okSignals, taskId: 'meeting-minutes', }); expect(g.severity).toBe('none'); expect(g.summary).toBe('갭 없음'); }); }); describe('computeNeeds', () => { it('약한 그라운딩·누락 많은 업무가 높은 점수를 받는다', () => { const records: ReflectionRecord[] = [ // 회의록: 깨끗한 수행 3회 mkReflection({}), mkReflection({}), mkReflection({}), // 시장조사: 근거 없음 + 누락 + 저확신 2회 mkReflection({ taskId: 'market-research', taskLabel: '시장조사', weakGrounding: true, missing: ['출처', '시장 규모'], confidenceScore: 40, retrieval: { chunkCount: 0, topScore: 0 } }), mkReflection({ taskId: 'market-research', taskLabel: '시장조사', weakGrounding: true, missing: ['출처'], confidenceScore: 45, retrieval: { chunkCount: 0, topScore: 0 } }), ]; const needs = computeNeeds(records); expect(needs[0].taskId).toBe('market-research'); expect(needs[0].score).toBeGreaterThan(needs[1].score); expect(needs[0].topMisses).toContain('출처'); expect(needs[0].reason).toContain('누락'); }); it('가중치 합이 1', () => { const sum = Object.values(NEED_WEIGHTS).reduce((s, w) => s + w, 0); expect(sum).toBeCloseTo(1.0); }); it('기록 없으면 빈 배열 + md 안내', () => { expect(computeNeeds([])).toEqual([]); expect(formatNeedsMarkdown([], [])).toContain('기록 없음'); }); }); describe('knowledgeInventory', () => { it('그라운딩 평균으로 보유/부족/없음 판정', () => { const records: ReflectionRecord[] = [ mkReflection({ retrieval: { chunkCount: 5, topScore: 0.8 } }), mkReflection({ taskId: 'market-research', taskLabel: '시장조사', retrieval: { chunkCount: 0, topScore: 0 } }), mkReflection({ taskId: 'work-research', taskLabel: '업무조사', retrieval: { chunkCount: 1, topScore: 0.3 } }), ]; const inv = knowledgeInventory(records); const byId = new Map(inv.map((i) => [i.taskId, i.status])); expect(byId.get('meeting-minutes')).toBe('sufficient'); expect(byId.get('market-research')).toBe('missing'); expect(byId.get('work-research')).toBe('partial'); }); }); describe('learningQueue', () => { const needs = computeNeeds([ mkReflection({ taskId: 'market-research', taskLabel: '시장조사', weakGrounding: true, missing: ['출처'], confidenceScore: 40 }), ]); it('save → load 라운드트립 + 우선순위 정렬 저장', () => { const brain = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-test-queue-')); const queue = mergeNeedsIntoQueue([], needs, '2026-06-11T00:00:00.000Z'); expect(saveQueue(brain, queue)).toBe(true); const loaded = loadQueue(brain); expect(loaded.length).toBe(1); expect(loaded[0].status).toBe('proposed'); expect(loaded[0].topic).toContain('시장조사'); }); it('proposed 는 갱신되지만 approved 는 불변 (Permission Based Learning)', () => { const approved: QueueItem = { id: 'need-market-research', topic: '시장조사 역량 보강', priority: 10, reason: '이전', status: 'approved', createdAt: 'a', updatedAt: 'a', }; const merged = mergeNeedsIntoQueue([approved], needs, '2026-06-11T00:00:00.000Z'); expect(merged.length).toBe(1); expect(merged[0].status).toBe('approved'); expect(merged[0].priority).toBe(10); // Need 점수로 덮어쓰지 않음 expect(merged[0].reason).toBe('이전'); }); it('새 주제는 proposed 로 추가된다', () => { const other: QueueItem = { id: 'need-schedule', topic: '일정', priority: 5, reason: 'r', status: 'done', createdAt: 'a', updatedAt: 'a', }; const merged = mergeNeedsIntoQueue([other], needs, 'now'); expect(merged.length).toBe(2); expect(merged.find((q) => q.id === 'need-market-research')?.status).toBe('proposed'); expect(merged.find((q) => q.id === 'need-schedule')?.status).toBe('done'); // 불변 }); it('formatQueueMarkdown — 승인 안내 포함', () => { const md = formatQueueMarkdown(mergeNeedsIntoQueue([], needs, 'now')); expect(md).toContain('approved'); expect(md).toContain('시장조사'); }); });