feat: Self-Evolving Digital Employee OS P0~P6 + 캘린더 충돌 게이트
신뢰성 코어 (P1~P2): - Requirement Graph: 업무 유형(회의록/시장조사/업무조사/일정) 필수 요소 주입 + 커버리지 hook - Confidence Engine(0~100 결정론적) / Escalation Engine(검토 요청) / Epistemic Guard(모름·추정·확실 3분류) - Provenance: citationTrace 에 출처 수정일·오래됨 경고 - Critic Loop: 문제 신호 turn 만 LLM 검수 1회 + 보완 카드 성장 루프 (P3): - Gap Detector(Requirement-Knowledge) / Need Engine(30/25/20/15/10 공식) / Knowledge Inventory - Learning Queue(proposed 전용 병합 — 승인은 사람만) / Decision Journal / Reflection 기록 - 반복 누락 요소(3회+)는 다음 turn 체크리스트에 자동 강조 (T5 루프) 지식 운영 (P4) + 기억 (P5) + 학습 실행 (P6): - Knowledge Validation + Belief Revision(중복 reject·충돌 시 update/add 권고) - Knowledge Decay(분야별 반감기 감사) / Knowledge Debt(blocked x impact) - Organizational Memory(.astra/organization.md 상시 주입) - Research Agent(approved 큐 -> 조사 브리프+추정 라벨 초안+Validation 게이트 -> proposals/) - Skill Score(전/후반 추세) + Success Pattern DB(전요소충족+확신도90+ 자동 적재) 병렬 트랙: - 캘린더 충돌 게이트: conflictCheck + 구조화 이벤트 캐시 + create_calendar_event 차단(force 는 사용자 승인 후) - Task Eval Harness: 회의록 골든셋 자동 채점 명령 + 성장 리포트/학습 큐/노후 점검 명령 신규 모듈 17종(src/intelligence/), VS Code 명령 5종, 설정 11종, 테스트 +89건(전체 508 통과). 설계 문서: docs/SELF_EVOLVING_OS_MASTER_PLAN.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Confidence Engine + Escalation Engine (Self-Evolving OS Phase 2) 단위 테스트.
|
||||
* 순수 함수만 검증 — vscode 의존 없음.
|
||||
*/
|
||||
import {
|
||||
extractAnswerSignals,
|
||||
computeConfidence,
|
||||
formatConfidenceFooter,
|
||||
toBand,
|
||||
RetrievalConfidenceSignals,
|
||||
} from '../src/intelligence/confidenceEngine';
|
||||
import { decideEscalation, formatEscalationFooter } from '../src/intelligence/escalationEngine';
|
||||
import { buildEpistemicGuardBlock } from '../src/intelligence/epistemicGuardBlock';
|
||||
import { buildCitationTraceBlock } from '../src/retrieval/citationTrace';
|
||||
import type { RetrievalChunk } from '../src/retrieval/types';
|
||||
|
||||
const strongRetrieval: RetrievalConfidenceSignals = {
|
||||
chunkCount: 5, topScore: 0.82, conflictCount: 0, ambiguityDetected: false,
|
||||
};
|
||||
const noRetrieval: RetrievalConfidenceSignals = {
|
||||
chunkCount: 0, topScore: 0, conflictCount: 0, ambiguityDetected: false,
|
||||
};
|
||||
|
||||
describe('extractAnswerSignals', () => {
|
||||
it('헤지 마커와 출처 인용을 추출한다', () => {
|
||||
const s = extractAnswerSignals('시장 규모는 5조원으로 추정됩니다. (확인 필요)\n\n*출처:* `시장조사.md`', 0);
|
||||
expect(s.hedgeCount).toBe(2);
|
||||
expect(s.hasCitation).toBe(true);
|
||||
expect(s.modelKnowledgeOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('모델 지식만 사용 표기를 구분한다', () => {
|
||||
const s = extractAnswerSignals('일반적인 설명입니다.\n\n*출처: 모델 지식 (검색 출처 미사용)*', null);
|
||||
expect(s.hasCitation).toBe(false);
|
||||
expect(s.modelKnowledgeOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeConfidence', () => {
|
||||
it('강한 그라운딩 + 출처 인용 + 커버리지 충족 → 높음(90+)', () => {
|
||||
const r = computeConfidence(strongRetrieval, {
|
||||
hedgeCount: 0, hasCitation: true, modelKnowledgeOnly: false, coverageMissing: 0,
|
||||
});
|
||||
expect(r.score).toBeGreaterThanOrEqual(90);
|
||||
expect(r.band).toBe('high');
|
||||
});
|
||||
|
||||
it('근거 없음 + 모델 지식만 → 매우 낮음(<50)', () => {
|
||||
const r = computeConfidence(noRetrieval, {
|
||||
hedgeCount: 2, hasCitation: false, modelKnowledgeOnly: true, coverageMissing: null,
|
||||
});
|
||||
expect(r.score).toBeLessThan(50);
|
||||
expect(r.band).toBe('very-low');
|
||||
});
|
||||
|
||||
it('충돌·모호성·커버리지 누락이 점수를 깎는다', () => {
|
||||
const clean = computeConfidence(strongRetrieval, {
|
||||
hedgeCount: 0, hasCitation: true, modelKnowledgeOnly: false, coverageMissing: 0,
|
||||
});
|
||||
const dirty = computeConfidence(
|
||||
{ ...strongRetrieval, conflictCount: 2, ambiguityDetected: true },
|
||||
{ hedgeCount: 0, hasCitation: true, modelKnowledgeOnly: false, coverageMissing: 3 },
|
||||
);
|
||||
expect(dirty.score).toBeLessThan(clean.score);
|
||||
expect(dirty.factors.some((f) => f.label.includes('충돌'))).toBe(true);
|
||||
});
|
||||
|
||||
it('점수는 0~100 으로 clamp 된다', () => {
|
||||
const r = computeConfidence(noRetrieval, {
|
||||
hedgeCount: 99, hasCitation: false, modelKnowledgeOnly: true, coverageMissing: 99,
|
||||
});
|
||||
expect(r.score).toBeGreaterThanOrEqual(0);
|
||||
expect(r.score).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('구간 경계 — 90/70/50', () => {
|
||||
expect(toBand(90)).toBe('high');
|
||||
expect(toBand(89)).toBe('medium');
|
||||
expect(toBand(70)).toBe('medium');
|
||||
expect(toBand(69)).toBe('low');
|
||||
expect(toBand(50)).toBe('low');
|
||||
expect(toBand(49)).toBe('very-low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideEscalation', () => {
|
||||
const coverageOk = { ran: true, taskId: 'meeting-minutes', taskLabel: '회의록', covered: ['참석자'], missing: [] as string[] };
|
||||
const noTask = { ran: false, covered: [] as string[], missing: [] as string[] };
|
||||
|
||||
function conf(score: number) {
|
||||
return { score, band: toBand(score), bandLabel: '', factors: [] };
|
||||
}
|
||||
|
||||
it('확신도 <50 이면 무조건 에스컬레이션', () => {
|
||||
const d = decideEscalation({ confidence: conf(40), coverage: noTask, conflictCount: 0 });
|
||||
expect(d.escalate).toBe(true);
|
||||
expect(d.reasons[0]).toContain('매우 낮음');
|
||||
});
|
||||
|
||||
it('고영향 업무(회의록) + 확신도 <70 → 검토 권장', () => {
|
||||
const d = decideEscalation({ confidence: conf(60), coverage: coverageOk, conflictCount: 0 });
|
||||
expect(d.escalate).toBe(true);
|
||||
expect(d.reasons.some((r) => r.includes('회의록'))).toBe(true);
|
||||
});
|
||||
|
||||
it('시장조사에서 출처 누락 → 단독 에스컬레이션', () => {
|
||||
const d = decideEscalation({
|
||||
confidence: conf(85),
|
||||
coverage: { ran: true, taskId: 'market-research', taskLabel: '시장조사', covered: [], missing: ['출처'] },
|
||||
conflictCount: 0,
|
||||
});
|
||||
expect(d.escalate).toBe(true);
|
||||
expect(d.reasons.some((r) => r.includes('출처'))).toBe(true);
|
||||
});
|
||||
|
||||
it('출처 충돌 + 확신도 <90 → 에스컬레이션', () => {
|
||||
const d = decideEscalation({ confidence: conf(80), coverage: noTask, conflictCount: 1 });
|
||||
expect(d.escalate).toBe(true);
|
||||
});
|
||||
|
||||
it('확신도 높음 + 충돌 없음 + 커버리지 충족 → 에스컬레이션 없음', () => {
|
||||
const d = decideEscalation({ confidence: conf(95), coverage: coverageOk, conflictCount: 0 });
|
||||
expect(d.escalate).toBe(false);
|
||||
expect(formatEscalationFooter(d)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatConfidenceFooter', () => {
|
||||
it('점수·구간·상위 요인을 표시한다', () => {
|
||||
const r = computeConfidence(strongRetrieval, {
|
||||
hedgeCount: 0, hasCitation: true, modelKnowledgeOnly: false, coverageMissing: 0,
|
||||
});
|
||||
const f = formatConfidenceFooter(r);
|
||||
expect(f).toContain(`확신도 ${r.score}/100`);
|
||||
expect(f).toContain('높음');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEpistemicGuardBlock', () => {
|
||||
it('근거 없는 업무 turn 에 역질문 우선 지시가 들어간다', () => {
|
||||
const b = buildEpistemicGuardBlock({ chunkCount: 0, taskDetected: true });
|
||||
expect(b).toContain('검색 근거가 없음');
|
||||
expect(b).toContain('질문');
|
||||
});
|
||||
|
||||
it('근거 있는 turn 은 3분류 규칙만', () => {
|
||||
const b = buildEpistemicGuardBlock({ chunkCount: 4, taskDetected: false });
|
||||
expect(b).toContain('확인 필요');
|
||||
expect(b).not.toContain('검색 근거가 없음');
|
||||
});
|
||||
});
|
||||
|
||||
describe('citationTrace Provenance 확장', () => {
|
||||
const mkChunk = (title: string, lastUpdated?: number): RetrievalChunk => ({
|
||||
id: title, source: 'brain-memory' as any, title, content: 'body', score: 0.8, tokenEstimate: 1,
|
||||
metadata: { lastUpdated },
|
||||
});
|
||||
const NOW = new Date('2026-06-11T00:00:00Z').getTime();
|
||||
|
||||
it('수정일 메타데이터가 있으면 Provenance 섹션 표시 + 오래된 출처 경고', () => {
|
||||
const fresh = mkChunk('최근문서', NOW - 10 * 24 * 3600 * 1000);
|
||||
const stale = mkChunk('옛문서', NOW - 400 * 24 * 3600 * 1000);
|
||||
const b = buildCitationTraceBlock([fresh, stale], { nowMs: NOW });
|
||||
expect(b).toContain('Provenance');
|
||||
expect(b).toContain('최근문서');
|
||||
expect(b).toContain('⚠️오래됨');
|
||||
expect(b).toContain('현재와 다를 수 있음');
|
||||
});
|
||||
|
||||
it('메타데이터 없으면 기존 블록과 동일 (Provenance 섹션 없음)', () => {
|
||||
const b = buildCitationTraceBlock([mkChunk('문서')], { nowMs: NOW });
|
||||
expect(b).toContain('[CITATION TRACE]');
|
||||
expect(b).not.toContain('Provenance');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Schedule Conflict Check (Self-Evolving OS Track 6-2/6-3) 테스트.
|
||||
*/
|
||||
import { findScheduleConflicts, formatConflictReport, CachedCalEvent } from '../src/features/calendar/conflictCheck';
|
||||
import { _parseCalEventAttrs } from '../src/agent/attrParsers';
|
||||
|
||||
// 로컬 ISO (timezone 없음) — 실제 캐시도 로컬 자정 기준 all-day 를 담으므로
|
||||
// 테스트를 실행 머신 timezone 과 무관하게 만든다.
|
||||
const EXISTING: CachedCalEvent[] = [
|
||||
{ summary: '주간회의', startIso: '2026-06-12T14:00:00', endIso: '2026-06-12T15:00:00', allDay: false, location: '회의실 A' },
|
||||
{ summary: '워크숍', startIso: '2026-06-13T00:00:00', allDay: true },
|
||||
];
|
||||
|
||||
describe('findScheduleConflicts', () => {
|
||||
it('구간이 겹치면 충돌', () => {
|
||||
const c = findScheduleConflicts(EXISTING, { startIso: '2026-06-12T14:30:00', durationMinutes: 60 });
|
||||
expect(c.length).toBe(1);
|
||||
expect(c[0].summary).toBe('주간회의');
|
||||
});
|
||||
|
||||
it('경계 접촉(끝=시작)은 충돌 아님', () => {
|
||||
const c = findScheduleConflicts(EXISTING, { startIso: '2026-06-12T15:00:00', durationMinutes: 60 });
|
||||
expect(c.length).toBe(0);
|
||||
});
|
||||
|
||||
it('endIso 없으면 기본 60분으로 판정', () => {
|
||||
const c = findScheduleConflicts(EXISTING, { startIso: '2026-06-12T13:30:00' });
|
||||
expect(c.length).toBe(1); // 13:30~14:30 vs 14:00~15:00
|
||||
});
|
||||
|
||||
it('종일 일정과 그 날짜의 시간 일정은 충돌', () => {
|
||||
const c = findScheduleConflicts(EXISTING, { startIso: '2026-06-13T10:00:00', durationMinutes: 30 });
|
||||
expect(c.some((e) => e.summary === '워크숍')).toBe(true);
|
||||
});
|
||||
|
||||
it('잘못된 날짜 입력은 보수적으로 충돌 없음 (생성 단계에서 실패)', () => {
|
||||
expect(findScheduleConflicts(EXISTING, { startIso: 'not-a-date' })).toEqual([]);
|
||||
expect(findScheduleConflicts([{ summary: 'x', startIso: 'broken', allDay: false }], { startIso: '2026-06-12T14:00:00' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('빈 캐시면 충돌 없음', () => {
|
||||
expect(findScheduleConflicts([], { startIso: '2026-06-12T14:00:00' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatConflictReport', () => {
|
||||
it('충돌 목록 + force 안내 포함', () => {
|
||||
const msg = formatConflictReport([EXISTING[0]]);
|
||||
expect(msg).toContain('주간회의');
|
||||
expect(msg).toContain('force="true"');
|
||||
expect(msg).toContain('보류');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_parseCalEventAttrs force 속성', () => {
|
||||
it('force="true" 파싱', () => {
|
||||
const attrs = _parseCalEventAttrs(' title="미팅" start="2026-06-12T14:00" force="true"');
|
||||
expect(attrs.force).toBe(true);
|
||||
});
|
||||
|
||||
it('미지정이면 undefined (기본 차단 동작)', () => {
|
||||
const attrs = _parseCalEventAttrs(' title="미팅" start="2026-06-12T14:00"');
|
||||
expect(attrs.force).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Critic Agent / Reflection Store / Task Eval Harness (Self-Evolving OS P1 잔여 + P3) 테스트.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
buildCritiquePrompt,
|
||||
parseCritique,
|
||||
runCriticReview,
|
||||
formatCriticFooter,
|
||||
} from '../src/intelligence/criticAgent';
|
||||
import {
|
||||
appendReflection,
|
||||
loadReflections,
|
||||
summarizeFailurePatterns,
|
||||
recurrentMisses,
|
||||
formatGrowthReport,
|
||||
ReflectionRecord,
|
||||
} from '../src/intelligence/reflectionStore';
|
||||
import {
|
||||
loadTaskGoldenSet,
|
||||
scoreTaskAnswer,
|
||||
runTaskEval,
|
||||
formatTaskEvalReport,
|
||||
TASK_GOLDEN_DIR,
|
||||
TaskGoldenRecord,
|
||||
} from '../src/intelligence/taskEvalHarness';
|
||||
import { DEFAULT_TASK_REQUIREMENTS, buildRequirementGraphBlock } from '../src/intelligence/requirementGraph';
|
||||
|
||||
const MEETING_REQ = DEFAULT_TASK_REQUIREMENTS.find((r) => r.id === 'meeting-minutes')!;
|
||||
|
||||
function tmpBrain(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-test-brain-'));
|
||||
}
|
||||
|
||||
function mkReflection(partial: Partial<ReflectionRecord>): ReflectionRecord {
|
||||
return {
|
||||
ts: '2026-06-11T10:00:00.000Z',
|
||||
taskId: 'meeting-minutes',
|
||||
taskLabel: '회의록',
|
||||
confidenceScore: 70,
|
||||
confidenceBand: 'medium',
|
||||
missing: [],
|
||||
escalated: false,
|
||||
criticIssues: null,
|
||||
promptPreview: '회의록 정리',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('criticAgent', () => {
|
||||
it('critique 프롬프트에 필수 요소와 누락 신호가 포함된다', () => {
|
||||
const { system, user } = buildCritiquePrompt('회의록 정리해줘', '초안...', MEETING_REQ, ['담당자', '기한']);
|
||||
expect(system).toContain('JSON');
|
||||
expect(user).toContain('담당자, 기한');
|
||||
expect(user).toContain('회의록');
|
||||
});
|
||||
|
||||
it('코드펜스·잡설 섞인 응답에서도 JSON 을 파싱한다', () => {
|
||||
const raw = '검토 결과입니다.\n```json\n{"pass": false, "issues": [{"severity": "major", "description": "기한 누락"}], "supplement": "## 기한\\n- (기한 미정)"}\n```';
|
||||
const c = parseCritique(raw);
|
||||
expect(c).not.toBeNull();
|
||||
expect(c!.pass).toBe(false);
|
||||
expect(c!.issues[0].severity).toBe('major');
|
||||
expect(c!.supplement).toContain('기한');
|
||||
});
|
||||
|
||||
it('pass=true 여도 issues 가 있으면 pass 취급하지 않는다', () => {
|
||||
const c = parseCritique('{"pass": true, "issues": [{"severity": "minor", "description": "x"}], "supplement": ""}');
|
||||
expect(c!.pass).toBe(false);
|
||||
});
|
||||
|
||||
it('runCriticReview — LLM 실패 시 null (silent skip)', async () => {
|
||||
const result = await runCriticReview({
|
||||
userPrompt: 'q', draft: 'd', requirement: MEETING_REQ, missingLabels: [],
|
||||
callLlm: async () => { throw new Error('LLM down'); },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('formatCriticFooter — pass 면 빈 문자열, 실패면 이슈+보완 표시', () => {
|
||||
expect(formatCriticFooter({ pass: true, issues: [], supplement: '' })).toBe('');
|
||||
const f = formatCriticFooter({
|
||||
pass: false,
|
||||
issues: [{ severity: 'major', description: '결정과 미결이 섞임' }],
|
||||
supplement: '## 보완',
|
||||
});
|
||||
expect(f).toContain('검수 (Critic)');
|
||||
expect(f).toContain('결정과 미결이 섞임');
|
||||
expect(f).toContain('보완 제안');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reflectionStore', () => {
|
||||
it('append → load 라운드트립', () => {
|
||||
const brain = tmpBrain();
|
||||
expect(appendReflection(brain, mkReflection({ missing: ['기한'] }))).toBe(true);
|
||||
expect(appendReflection(brain, mkReflection({ missing: ['기한', '담당자'] }))).toBe(true);
|
||||
const records = loadReflections(brain);
|
||||
expect(records.length).toBe(2);
|
||||
expect(records[1].missing).toEqual(['기한', '담당자']);
|
||||
});
|
||||
|
||||
it('summarizeFailurePatterns — 반복 누락 집계 (많은 순)', () => {
|
||||
const records = [
|
||||
mkReflection({ missing: ['기한'] }),
|
||||
mkReflection({ missing: ['기한'] }),
|
||||
mkReflection({ missing: ['기한', '담당자'] }),
|
||||
];
|
||||
const patterns = summarizeFailurePatterns(records);
|
||||
expect(patterns[0]).toMatchObject({ element: '기한', count: 3 });
|
||||
expect(patterns[1]).toMatchObject({ element: '담당자', count: 1 });
|
||||
});
|
||||
|
||||
it('recurrentMisses — threshold 이상만 반환', () => {
|
||||
const records = [
|
||||
mkReflection({ missing: ['기한'] }),
|
||||
mkReflection({ missing: ['기한'] }),
|
||||
mkReflection({ missing: ['기한'] }),
|
||||
mkReflection({ missing: ['담당자'] }),
|
||||
];
|
||||
expect(recurrentMisses(records, 'meeting-minutes', 3)).toEqual(['기한']);
|
||||
expect(recurrentMisses(records, 'market-research', 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it('반복 누락 요소가 Requirement Graph 블록에 강조된다 (T5 루프)', () => {
|
||||
const block = buildRequirementGraphBlock('회의록 정리해줘', undefined, ['기한']);
|
||||
expect(block).toContain('과거에 자주 누락된 요소');
|
||||
});
|
||||
|
||||
it('formatGrowthReport — 주별 추이 테이블 + 반복 실수 Top', () => {
|
||||
const records = [
|
||||
mkReflection({ ts: '2026-06-01T10:00:00.000Z', confidenceScore: 60, missing: ['기한'] }),
|
||||
mkReflection({ ts: '2026-06-09T10:00:00.000Z', confidenceScore: 85, missing: [] }),
|
||||
];
|
||||
const md = formatGrowthReport(records);
|
||||
expect(md).toContain('평균 확신도');
|
||||
expect(md).toContain('기한');
|
||||
expect(formatGrowthReport([])).toContain('기록 없음');
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskEvalHarness', () => {
|
||||
const record: TaskGoldenRecord = {
|
||||
id: 'mm-test',
|
||||
query: '이 회의 내용을 회의록으로 정리해줘',
|
||||
sourceFile: 'fake.txt',
|
||||
expectedElements: ['참석자', '결정사항', '액션 아이템', '담당자', '기한'],
|
||||
reference: 'ref',
|
||||
};
|
||||
|
||||
it('골든셋 로드 — 주석·깨진 줄 처리', () => {
|
||||
const brain = tmpBrain();
|
||||
const dir = path.join(brain, TASK_GOLDEN_DIR);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'meeting-minutes.golden.jsonl'), [
|
||||
'// 주석',
|
||||
JSON.stringify(record),
|
||||
'{broken',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
const { records, parseErrors } = loadTaskGoldenSet(brain);
|
||||
expect(records.length).toBe(1);
|
||||
expect(parseErrors).toBe(1);
|
||||
expect(records[0].id).toBe('mm-test');
|
||||
});
|
||||
|
||||
it('scoreTaskAnswer — 커버리지·정직성·구조 채점', () => {
|
||||
const answer = '# 회의록\n## 참석자: 김OO\n## 결정사항: A안\n## 액션 아이템\n- 발송 (담당자: 김OO, (기한 미정))';
|
||||
const s = scoreTaskAnswer(answer, record);
|
||||
expect(s.coverageRate).toBe(1);
|
||||
expect(s.honestyMarkers).toBeGreaterThanOrEqual(1);
|
||||
expect(s.sectionCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('runTaskEval — 생성 실패가 전체를 막지 않고 에러 레코드로 남는다', async () => {
|
||||
const result = await runTaskEval({
|
||||
records: [record, { ...record, id: 'mm-fail' }],
|
||||
readSource: () => '전사 내용',
|
||||
generate: async (r) => {
|
||||
if (r.id === 'mm-fail') throw new Error('engine down');
|
||||
return '## 참석자 a ## 결정사항 b ## 액션 아이템 c 담당자 d 기한 e';
|
||||
},
|
||||
});
|
||||
expect(result.scores.length).toBe(2);
|
||||
expect(result.scores[0].coverageRate).toBe(1);
|
||||
expect(result.scores[1].error).toContain('engine down');
|
||||
expect(result.avgCoverage).toBe(1); // 실패 레코드는 평균에서 제외
|
||||
});
|
||||
|
||||
it('formatTaskEvalReport — 요약·테이블 포함', () => {
|
||||
const md = formatTaskEvalReport(
|
||||
{ scores: [scoreTaskAnswer('참석자 결정사항', record)], avgCoverage: 0.4, perfectCount: 0 },
|
||||
{ taskLabel: '회의록', brainName: 'B', dateStr: 'now', modelName: 'gemma' },
|
||||
);
|
||||
expect(md).toContain('평균 요소 커버리지');
|
||||
expect(md).toContain('mm-test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Knowledge Validation / Belief Revision / Decay / Debt
|
||||
* (Self-Evolving OS Phase 4 — 지식 운영) 테스트.
|
||||
*/
|
||||
import {
|
||||
validateKnowledgeCandidate,
|
||||
jaccardSimilarity,
|
||||
ExistingKnowledgeRef,
|
||||
} from '../src/intelligence/knowledgeValidation';
|
||||
import {
|
||||
auditKnowledgeDecay,
|
||||
classifyDecayRule,
|
||||
decayFactor,
|
||||
formatDecayReport,
|
||||
} from '../src/intelligence/knowledgeDecay';
|
||||
import { computeKnowledgeDebt, formatNeedsMarkdown } from '../src/intelligence/needEngine';
|
||||
import type { ReflectionRecord } from '../src/intelligence/reflectionStore';
|
||||
|
||||
const NOW = Date.parse('2026-06-11T00:00:00Z');
|
||||
const DAY = 86400000;
|
||||
|
||||
describe('knowledgeValidation', () => {
|
||||
const existing: ExistingKnowledgeRef[] = [
|
||||
{
|
||||
title: 'GA4 전환율 가이드',
|
||||
content: 'GA4 전환율 계산은 전환수 나누기 세션수 기준이며 보고서는 탐색 분석에서 본다',
|
||||
lastUpdated: NOW - 100 * DAY,
|
||||
},
|
||||
];
|
||||
|
||||
it('출처 있고 중복/충돌 없는 신선한 후보 → accept', () => {
|
||||
const r = validateKnowledgeCandidate(
|
||||
{ title: '쿠팡 SEO', content: '쿠팡 검색 알고리즘은 판매량과 리뷰 점수를 핵심 신호로 사용한다', source: 'https://example.com', collectedAt: '2026-06-10T00:00:00Z' },
|
||||
existing, { nowMs: NOW },
|
||||
);
|
||||
expect(r.verdict).toBe('accept');
|
||||
expect(r.beliefRevision).toBe('add');
|
||||
});
|
||||
|
||||
it('출처 없으면 자동 수용 불가 (review)', () => {
|
||||
const r = validateKnowledgeCandidate(
|
||||
{ title: 't', content: '완전히 새로운 내용의 지식 후보입니다 검증 테스트' },
|
||||
existing, { nowMs: NOW },
|
||||
);
|
||||
expect(r.verdict).toBe('review');
|
||||
expect(r.checks.hasSource).toBe(false);
|
||||
});
|
||||
|
||||
it('거의 동일한 내용 → 중복 reject', () => {
|
||||
const r = validateKnowledgeCandidate(
|
||||
{ title: 'GA4', content: 'GA4 전환율 계산은 전환수 나누기 세션수 기준이며 보고서는 탐색 분석에서 본다', source: 's', collectedAt: '2026-06-10T00:00:00Z' },
|
||||
existing, { nowMs: NOW },
|
||||
);
|
||||
expect(r.verdict).toBe('reject');
|
||||
expect(r.checks.duplicateOf).toBe('GA4 전환율 가이드');
|
||||
});
|
||||
|
||||
it('관련/충돌 + 후보가 더 최신 → review + update 권고 (Belief Revision)', () => {
|
||||
const r = validateKnowledgeCandidate(
|
||||
{ title: 'GA4 변경', content: 'GA4 전환율 계산은 이제 전환수 나누기 사용자수 기준으로 변경되었다 보고서 위치도 다르다', source: 's', collectedAt: '2026-06-01T00:00:00Z' },
|
||||
existing, { nowMs: NOW },
|
||||
);
|
||||
expect(r.verdict).toBe('review');
|
||||
expect(r.checks.conflictsWith).toBe('GA4 전환율 가이드');
|
||||
expect(r.beliefRevision).toBe('update');
|
||||
});
|
||||
|
||||
it('수집일이 1년 이상 경과 → stale review', () => {
|
||||
const r = validateKnowledgeCandidate(
|
||||
{ title: 't', content: '전혀 다른 주제의 오래된 지식 항목', source: 's', collectedAt: '2024-01-01T00:00:00Z' },
|
||||
existing, { nowMs: NOW },
|
||||
);
|
||||
expect(r.verdict).toBe('review');
|
||||
expect(r.checks.freshness).toBe('stale');
|
||||
});
|
||||
|
||||
it('jaccardSimilarity — 동일 1.0, 무관 ~0', () => {
|
||||
expect(jaccardSimilarity('같은 문장 테스트', '같은 문장 테스트')).toBe(1);
|
||||
expect(jaccardSimilarity('완전히 다른 내용', '전혀 무관한 주제')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledgeDecay', () => {
|
||||
it('분야 분류 — AI 30일, SEO 90일, 기본 365일', () => {
|
||||
expect(classifyDecayRule('Topics/RAG_청킹_전략.md').halfLifeDays).toBe(30);
|
||||
expect(classifyDecayRule('Topics/네이버_SEO_가이드.md').halfLifeDays).toBe(90);
|
||||
expect(classifyDecayRule('Topics/요리_레시피.md').halfLifeDays).toBe(365);
|
||||
});
|
||||
|
||||
it('decayFactor — 반감기 경과 시 0.5', () => {
|
||||
expect(decayFactor(NOW - 30 * DAY, 30, NOW)).toBeCloseTo(0.5, 2);
|
||||
expect(decayFactor(NOW, 30, NOW)).toBe(1);
|
||||
});
|
||||
|
||||
it('audit — stale 우선 정렬 + 상태 판정', () => {
|
||||
const items = auditKnowledgeDecay([
|
||||
{ relPath: 'ai_guide.md', lastUpdated: NOW - 90 * DAY }, // AI 30일 반감 → 0.125 stale
|
||||
{ relPath: '요리.md', lastUpdated: NOW - 30 * DAY }, // 일반 365일 → ~0.94 active
|
||||
], { nowMs: NOW });
|
||||
expect(items[0].relPath).toBe('ai_guide.md');
|
||||
expect(items[0].status).toBe('stale');
|
||||
expect(items[1].status).toBe('active');
|
||||
});
|
||||
|
||||
it('formatDecayReport — 요약·권고 포함, 자동 삭제 없음 명시', () => {
|
||||
const items = auditKnowledgeDecay([{ relPath: 'ai.md', lastUpdated: NOW - 200 * DAY }], { nowMs: NOW });
|
||||
const md = formatDecayReport(items, { brainName: 'B', dateStr: 'now' });
|
||||
expect(md).toContain('노후 1');
|
||||
expect(md).toContain('자동 이동/삭제 없음');
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledgeDebt', () => {
|
||||
function mk(partial: Partial<ReflectionRecord>): ReflectionRecord {
|
||||
return {
|
||||
ts: '2026-06-11T10:00:00.000Z', taskId: 'market-research', taskLabel: '시장조사',
|
||||
confidenceScore: 50, confidenceBand: 'low', missing: [], escalated: false,
|
||||
criticIssues: null, promptPreview: 'p', weakGrounding: true, gapSeverity: 'high',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
it('근거 없는 수행 turn 을 업무별로 집계, debtScore 정렬', () => {
|
||||
const debt = computeKnowledgeDebt([
|
||||
mk({}), mk({}), mk({ gapSeverity: 'medium' }),
|
||||
mk({ taskId: 'meeting-minutes', taskLabel: '회의록', gapSeverity: 'low' }),
|
||||
mk({ taskId: 'meeting-minutes', taskLabel: '회의록', weakGrounding: false }), // 부채 아님
|
||||
]);
|
||||
expect(debt[0].taskId).toBe('market-research');
|
||||
expect(debt[0].blockedTurns).toBe(3);
|
||||
expect(debt[0].impact).toBeGreaterThan(5);
|
||||
expect(debt.find((d) => d.taskId === 'meeting-minutes')!.blockedTurns).toBe(1);
|
||||
});
|
||||
|
||||
it('formatNeedsMarkdown 에 Debt 섹션 포함', () => {
|
||||
const debt = computeKnowledgeDebt([mk({})]);
|
||||
const md = formatNeedsMarkdown([], [], debt);
|
||||
expect(md).toContain('Knowledge Debt');
|
||||
expect(md).toContain('시장조사');
|
||||
});
|
||||
});
|
||||
|
||||
describe('orgMemoryBlock (P5)', () => {
|
||||
const fsMod = require('fs');
|
||||
const osMod = require('os');
|
||||
const pathMod = require('path');
|
||||
const { buildOrgMemoryBlock, ORG_MEMORY_REL_PATH } = require('../src/intelligence/orgMemoryBlock');
|
||||
|
||||
it('organization.md 가 있으면 블록 주입 + Human Override 명시', () => {
|
||||
const brain = fsMod.mkdtempSync(pathMod.join(osMod.tmpdir(), 'astra-test-org-'));
|
||||
const file = pathMod.join(brain, ORG_MEMORY_REL_PATH);
|
||||
fsMod.mkdirSync(pathMod.dirname(file), { recursive: true });
|
||||
fsMod.writeFileSync(file, '## 업무 방식\n- 속도 우선, 완벽주의 지양', 'utf8');
|
||||
const block = buildOrgMemoryBlock(brain);
|
||||
expect(block).toContain('[ORGANIZATIONAL MEMORY]');
|
||||
expect(block).toContain('속도 우선');
|
||||
expect(block).toContain('사용자 지시 우선');
|
||||
});
|
||||
|
||||
it('파일 없으면 빈 문자열 (no-op)', () => {
|
||||
const brain = fsMod.mkdtempSync(pathMod.join(osMod.tmpdir(), 'astra-test-org-'));
|
||||
expect(buildOrgMemoryBlock(brain)).toBe('');
|
||||
});
|
||||
|
||||
it('본문이 길면 cap + 잘림 안내', () => {
|
||||
const brain = fsMod.mkdtempSync(pathMod.join(osMod.tmpdir(), 'astra-test-org-'));
|
||||
const file = pathMod.join(brain, ORG_MEMORY_REL_PATH);
|
||||
fsMod.mkdirSync(pathMod.dirname(file), { recursive: true });
|
||||
fsMod.writeFileSync(file, 'x'.repeat(5000), 'utf8');
|
||||
const block = buildOrgMemoryBlock(brain, { maxBodyLength: 1000 });
|
||||
expect(block).toContain('잘림');
|
||||
expect(block.length).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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>): 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('시장조사');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Requirement Graph (Self-Evolving OS Phase 1 / Track 2-1) 단위 테스트.
|
||||
* 순수 함수만 검증 — vscode 의존 없음.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_TASK_REQUIREMENTS,
|
||||
detectTaskType,
|
||||
buildRequirementGraphBlock,
|
||||
checkRequirementCoverage,
|
||||
formatRequirementCoverageFooter,
|
||||
} from '../src/intelligence/requirementGraph';
|
||||
|
||||
describe('detectTaskType', () => {
|
||||
it('회의록 요청을 감지한다', () => {
|
||||
expect(detectTaskType('오늘 주간회의 내용 회의록으로 정리해줘')?.id).toBe('meeting-minutes');
|
||||
expect(detectTaskType('어제 미팅 노트 만들어줘')?.id).toBe('meeting-minutes');
|
||||
});
|
||||
|
||||
it('시장조사 요청을 감지한다', () => {
|
||||
expect(detectTaskType('전기차 충전 인프라 시장조사 해줘')?.id).toBe('market-research');
|
||||
expect(detectTaskType('국내 로봇청소기 시장 규모 분석 부탁해')?.id).toBe('market-research');
|
||||
});
|
||||
|
||||
it('일정 관리 요청을 감지한다', () => {
|
||||
expect(detectTaskType('내일 3시에 미팅 잡아줘')?.id).toBe('schedule');
|
||||
expect(detectTaskType('이번 주 일정 확인해줘')?.id).toBe('schedule');
|
||||
});
|
||||
|
||||
it('범용 조사 요청은 업무조사로 감지한다 (시장조사보다 후순위)', () => {
|
||||
expect(detectTaskType('MCP 프로토콜에 대해 조사해줘')?.id).toBe('work-research');
|
||||
});
|
||||
|
||||
it('일반 잡담·빈 입력은 null', () => {
|
||||
expect(detectTaskType('안녕! 오늘 기분 어때?')).toBeNull();
|
||||
expect(detectTaskType('')).toBeNull();
|
||||
expect(detectTaskType(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRequirementGraphBlock', () => {
|
||||
it('회의록 블록에 필수 요소 5종이 모두 포함된다', () => {
|
||||
const block = buildRequirementGraphBlock('회의록 정리해줘');
|
||||
expect(block).toContain('[TASK REQUIREMENTS — 회의록]');
|
||||
for (const label of ['참석자', '결정사항', '액션 아이템', '담당자', '기한']) {
|
||||
expect(block).toContain(label);
|
||||
}
|
||||
expect(block).toContain('(확인 필요)'); // 조용한 생략 금지 지시
|
||||
expect(block).toContain('[/TASK REQUIREMENTS]');
|
||||
});
|
||||
|
||||
it('업무 유형 미감지 시 빈 문자열 (dynamicBlocks join 에서 자동 제외)', () => {
|
||||
expect(buildRequirementGraphBlock('고마워!')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRequirementCoverage', () => {
|
||||
const fullMinutes = [
|
||||
'# 주간회의 회의록',
|
||||
'## 참석자: 김OO, 이OO',
|
||||
'## 결정사항: A안 채택',
|
||||
'## 액션 아이템',
|
||||
'- 견적서 발송 (담당자: 김OO, 기한: 6월 20일까지)',
|
||||
].join('\n');
|
||||
|
||||
it('모든 요소가 있으면 missing 이 빈 배열', () => {
|
||||
const r = checkRequirementCoverage('회의록 정리해줘', fullMinutes);
|
||||
expect(r.ran).toBe(true);
|
||||
expect(r.taskId).toBe('meeting-minutes');
|
||||
expect(r.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('담당자·기한 누락을 검출한다', () => {
|
||||
const partial = '# 회의록\n참석자: 김OO\n결정사항: A안 채택\n액션 아이템: 견적서 발송';
|
||||
const r = checkRequirementCoverage('회의록 정리해줘', partial);
|
||||
expect(r.ran).toBe(true);
|
||||
expect(r.missing).toContain('담당자');
|
||||
expect(r.missing).toContain('기한');
|
||||
expect(r.covered).toContain('참석자');
|
||||
});
|
||||
|
||||
it('coverageCheck=false 업무(일정)는 검사하지 않는다', () => {
|
||||
const r = checkRequirementCoverage('내일 3시 미팅 잡아줘', '등록했습니다.');
|
||||
expect(r.ran).toBe(false);
|
||||
});
|
||||
|
||||
it('업무 유형 미감지·빈 답변이면 ran=false', () => {
|
||||
expect(checkRequirementCoverage('안녕', '반가워요').ran).toBe(false);
|
||||
expect(checkRequirementCoverage('회의록 정리해줘', ' ').ran).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRequirementCoverageFooter', () => {
|
||||
it('누락이 있으면 footer 에 업무명과 누락 요소를 표시', () => {
|
||||
const footer = formatRequirementCoverageFooter({
|
||||
ran: true, taskId: 'meeting-minutes', taskLabel: '회의록',
|
||||
covered: ['참석자'], missing: ['담당자', '기한'],
|
||||
});
|
||||
expect(footer).toContain('회의록');
|
||||
expect(footer).toContain('담당자, 기한');
|
||||
});
|
||||
|
||||
it('전부 충족 또는 미실행이면 빈 문자열 (노이즈 방지)', () => {
|
||||
expect(formatRequirementCoverageFooter({ ran: true, covered: ['참석자'], missing: [] })).toBe('');
|
||||
expect(formatRequirementCoverageFooter({ ran: false, covered: [], missing: [] })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_TASK_REQUIREMENTS 무결성', () => {
|
||||
it('모든 detectKeywords / detectPatterns 가 유효한 정규식이다', () => {
|
||||
for (const req of DEFAULT_TASK_REQUIREMENTS) {
|
||||
expect(() => new RegExp(req.detectKeywords.join('|'), 'iu')).not.toThrow();
|
||||
for (const el of req.elements) {
|
||||
expect(() => new RegExp(el.detectPatterns.join('|'), 'iu')).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('업무 ID 와 요소 ID 가 중복되지 않는다', () => {
|
||||
const taskIds = DEFAULT_TASK_REQUIREMENTS.map((r) => r.id);
|
||||
expect(new Set(taskIds).size).toBe(taskIds.length);
|
||||
for (const req of DEFAULT_TASK_REQUIREMENTS) {
|
||||
const ids = req.elements.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Research Agent / Skill Score / Success Pattern DB (Self-Evolving OS Phase 6) 테스트.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { parseBrief, fallbackBrief, runResearch, formatProposalMarkdown } from '../src/intelligence/researchAgent';
|
||||
import {
|
||||
computeSkillScores,
|
||||
formatSkillScoresMarkdown,
|
||||
isSuccessTurn,
|
||||
appendSuccessPattern,
|
||||
loadSuccessPatterns,
|
||||
} from '../src/intelligence/skillScore';
|
||||
import type { QueueItem } from '../src/intelligence/learningQueue';
|
||||
import type { ReflectionRecord } from '../src/intelligence/reflectionStore';
|
||||
|
||||
const ITEM: QueueItem = {
|
||||
id: 'need-market-research', topic: '시장조사 역량 보강', priority: 60, reason: '근거 없는 수행 다수',
|
||||
status: 'approved', createdAt: 'a', updatedAt: 'a',
|
||||
};
|
||||
|
||||
function mk(partial: Partial<ReflectionRecord>): ReflectionRecord {
|
||||
return {
|
||||
ts: '2026-06-11T10:00:00.000Z', taskId: 'meeting-minutes', taskLabel: '회의록',
|
||||
confidenceScore: 80, confidenceBand: 'medium', missing: [], escalated: false,
|
||||
criticIssues: null, promptPreview: '회의록 정리해줘', usedSources: ['회의기록.md'],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('researchAgent', () => {
|
||||
it('parseBrief — 잡설 섞인 JSON 파싱, 실패 시 fallback', () => {
|
||||
const ok = parseBrief('계획: {"questions":["q1","q2"],"keywords":["k"],"sourceTypes":["공식 문서"]} 끝');
|
||||
expect(ok!.questions).toEqual(['q1', 'q2']);
|
||||
expect(parseBrief('JSON 없음')).toBeNull();
|
||||
expect(fallbackBrief('주제').questions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('runResearch — 브리프→내부현황→초안→Validation 게이트 (출처 없음 = review)', async () => {
|
||||
const calls: string[] = [];
|
||||
const pkg = await runResearch({
|
||||
item: ITEM,
|
||||
fetchInternalRefs: async () => [{ title: '기존문서', content: '기존 시장조사 노트 내용', filePath: 'a.md' }],
|
||||
callLlm: async (system) => {
|
||||
calls.push(system.slice(0, 20));
|
||||
if (system.includes('조사 계획')) {
|
||||
return '{"questions":["시장 규모 출처는?"],"keywords":["시장조사"],"sourceTypes":["공식 통계"]}';
|
||||
}
|
||||
return '## 시장 규모\n일반적으로 통계청 자료를 쓴다 (모델 지식 — 추정, 출처 확인 필요)';
|
||||
},
|
||||
nowIso: '2026-06-11T00:00:00.000Z',
|
||||
});
|
||||
expect(pkg.brief.questions[0]).toContain('시장 규모');
|
||||
expect(pkg.internalRefs.length).toBe(1);
|
||||
expect(pkg.draft).toContain('추정');
|
||||
// 출처가 없으므로 자동 수용 불가 — Permission Based Learning 게이트.
|
||||
expect(pkg.validation.verdict).toBe('review');
|
||||
expect(pkg.validation.checks.hasSource).toBe(false);
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('LLM 전부 실패해도 fallback 브리프로 패키지 생성', async () => {
|
||||
const pkg = await runResearch({
|
||||
item: ITEM,
|
||||
fetchInternalRefs: async () => [],
|
||||
callLlm: async () => { throw new Error('down'); },
|
||||
nowIso: '2026-06-11T00:00:00.000Z',
|
||||
});
|
||||
expect(pkg.brief.questions.length).toBeGreaterThan(0);
|
||||
expect(pkg.draft).toContain('실패');
|
||||
});
|
||||
|
||||
it('formatProposalMarkdown — 판정·브리프·다음 단계 포함', async () => {
|
||||
const pkg = await runResearch({
|
||||
item: ITEM, fetchInternalRefs: async () => [],
|
||||
callLlm: async () => '{"questions":["q"],"keywords":["k"],"sourceTypes":["s"]}',
|
||||
nowIso: '2026-06-11T00:00:00.000Z',
|
||||
});
|
||||
const md = formatProposalMarkdown(pkg, { dateStr: 'now', modelName: 'gemma' });
|
||||
expect(md).toContain('검증 판정: review');
|
||||
expect(md).toContain('/research');
|
||||
expect(md).toContain('done 으로 변경');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skillScore', () => {
|
||||
it('확신도·충족률·비에스컬레이션 가중 합산 + 추세', () => {
|
||||
const records = [
|
||||
mk({ ts: '2026-06-01T10:00:00Z', confidenceScore: 50, missing: ['기한'], escalated: true }),
|
||||
mk({ ts: '2026-06-02T10:00:00Z', confidenceScore: 55, missing: ['기한'] }),
|
||||
mk({ ts: '2026-06-08T10:00:00Z', confidenceScore: 90, missing: [] }),
|
||||
mk({ ts: '2026-06-09T10:00:00Z', confidenceScore: 95, missing: [] }),
|
||||
];
|
||||
const scores = computeSkillScores(records);
|
||||
expect(scores.length).toBe(1);
|
||||
expect(scores[0].trend).toBe('up');
|
||||
expect(scores[0].secondHalf).toBeGreaterThan(scores[0].firstHalf);
|
||||
const md = formatSkillScoresMarkdown(scores);
|
||||
expect(md).toContain('상승');
|
||||
});
|
||||
|
||||
it('표본 4건 미만이면 추세 flat', () => {
|
||||
const scores = computeSkillScores([mk({}), mk({ confidenceScore: 20 })]);
|
||||
expect(scores[0].trend).toBe('flat');
|
||||
});
|
||||
|
||||
it('isSuccessTurn — 전 요소 충족 + 확신도 90+ 만', () => {
|
||||
expect(isSuccessTurn(mk({ confidenceScore: 92, missing: [] }))).toBe(true);
|
||||
expect(isSuccessTurn(mk({ confidenceScore: 92, missing: ['기한'] }))).toBe(false);
|
||||
expect(isSuccessTurn(mk({ confidenceScore: 80, missing: [] }))).toBe(false);
|
||||
});
|
||||
|
||||
it('append → load 성공 패턴 라운드트립 (성공 turn 만 저장)', () => {
|
||||
const brain = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-test-sp-'));
|
||||
expect(appendSuccessPattern(brain, mk({ confidenceScore: 95, missing: [] }))).toBe(true);
|
||||
expect(appendSuccessPattern(brain, mk({ confidenceScore: 50 }))).toBe(false);
|
||||
const patterns = loadSuccessPatterns(brain);
|
||||
expect(patterns.length).toBe(1);
|
||||
expect(patterns[0].usedSources).toEqual(['회의기록.md']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user