/** * Correction Loop 단위 테스트 — 순수 로직 (감지·프로필·레슨·큐 등록·영속화). * LLM 의존 부분(classifyCorrection)은 엔드포인트 실패 → 휴리스틱 fallback 경로만 검증. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { looksLikeCorrection, classifyCorrection, correctionLessonMarkdown, appendCorrectionCase, loadCorrectionCases, registerKnowledgeGap, computeWeaknessProfile, saveWeaknessProfile, loadWeaknessProfile, buildSelfReviewBlock, formatRegressionReport, type CorrectionCase, } from '../src/intelligence/correctionLoop'; import { loadQueue } from '../src/intelligence/learningQueue'; const tmpBrain = () => fs.mkdtempSync(path.join(os.tmpdir(), 'astra-corr-')); const CASE = (over: Partial = {}): CorrectionCase => ({ ts: new Date().toISOString(), errorTag: '사실오류', question: '회의가 언제였지?', wrongAnswer: '5월 10일입니다.', correction: '아니야, 6월 10일이야.', title: '회의 날짜 오답', ...over, }); describe('looksLikeCorrection — 정정 감지 (보수적)', () => { test.each([ '아니야, 그 회의는 6월이야', '틀렸어. 다시 확인해', '그게 아니라 화요일이야', '담당자는 김OO가 아니라 박OO야', '그 수치는 사실과 달라', '잘못 알고 있네 — 정정해줄게', '지어내지 마', ])('정정으로 감지: %s', (p) => expect(looksLikeCorrection(p)).toBe(true)); test.each([ '커밋하고 푸쉬해줘', '내일 일정 알려줘', '아니 그래서 결과가 어떻게 됐어?', // "아니" 단독 추임새는 비정정 '회의록 요약해줘', '', '응', ])('비정정: %s', (p) => expect(looksLikeCorrection(p)).toBe(false)); }); describe('classifyCorrection — LLM 실패 시 휴리스틱 fallback', () => { const deadLlm = { baseUrl: 'http://127.0.0.1:1', model: 'x' }; test('출처 언급 → 근거누락', async () => { const r = await classifyCorrection('q', 'a', '출처도 없이 단정하지 마', deadLlm); expect(r.tag).toBe('근거누락'); expect(r.title.length).toBeGreaterThan(0); }); test('맥락 언급 → 맥락누락', async () => { const r = await classifyCorrection('q', 'a', '아까 위에서 말했잖아', deadLlm); expect(r.tag).toBe('맥락누락'); }); test('기본 → 사실오류', async () => { const r = await classifyCorrection('q', 'a', '6월 10일이 맞아', deadLlm); expect(r.tag).toBe('사실오류'); }); }); describe('회귀 케이스 영속화', () => { test('append → load 라운드트립 + 필드 길이 제한', () => { const brain = tmpBrain(); const long = 'x'.repeat(2000); expect(appendCorrectionCase(brain, CASE({ wrongAnswer: long }))).toBe(true); expect(appendCorrectionCase(brain, CASE({ title: '두번째' }))).toBe(true); const cases = loadCorrectionCases(brain); expect(cases).toHaveLength(2); expect(cases[0].wrongAnswer.length).toBeLessThanOrEqual(600); expect(cases[1].title).toBe('두번째'); }); test('손상 라인은 건너뛴다', () => { const brain = tmpBrain(); appendCorrectionCase(brain, CASE()); fs.appendFileSync(path.join(brain, '.astra', 'eval', 'corrections.jsonl'), '{broken json\n'); appendCorrectionCase(brain, CASE({ title: 'b' })); expect(loadCorrectionCases(brain)).toHaveLength(2); }); }); describe('correctionLessonMarkdown', () => { test('error-tag frontmatter + Ground Truth 포함', () => { const md = correctionLessonMarkdown(CASE(), '2026-06-11'); expect(md).toContain('error-tag: 사실오류'); expect(md).toContain('source: user-correction'); expect(md).toContain('아니야, 6월 10일이야.'); expect(md).toMatch(/^---\ntype: lesson/); }); }); describe('registerKnowledgeGap — 학습 큐 자동 proposed', () => { test('등록 + 같은 질문 중복 차단', () => { const brain = tmpBrain(); expect(registerKnowledgeGap(brain, 'CRAG 교정 검색이 뭐야?', 0.12)).toBe(true); expect(registerKnowledgeGap(brain, 'CRAG 교정 검색이 뭐야?', 0.2)).toBe(false); const q = loadQueue(brain); expect(q).toHaveLength(1); expect(q[0].status).toBe('proposed'); expect(q[0].id).toMatch(/^gap-/); expect(q[0].reason).toContain('0.12'); }); test('짧은 질문 무시 + 20건 폭주 방지', () => { const brain = tmpBrain(); expect(registerKnowledgeGap(brain, '뭐야?', 0)).toBe(false); for (let i = 0; i < 25; i++) registerKnowledgeGap(brain, `지식 공백 질문 번호 ${i} 에 대한 상세 내용`, 0.1); expect(loadQueue(brain).length).toBeLessThanOrEqual(20); }); }); describe('약점 프로필', () => { const now = Date.parse('2026-06-11T00:00:00Z'); test('윈도우 필터 + 태그 집계 내림차순', () => { const cases = [ CASE({ ts: '2026-06-01T00:00:00Z', errorTag: '사실오류' }), CASE({ ts: '2026-06-05T00:00:00Z', errorTag: '사실오류', title: '최신 예시' }), CASE({ ts: '2026-06-03T00:00:00Z', errorTag: '근거누락' }), CASE({ ts: '2025-01-01T00:00:00Z', errorTag: '형식오류' }), // 윈도우 밖 ]; const p = computeWeaknessProfile(cases, now, 60); expect(p.totalCases).toBe(3); expect(p.tagCounts[0]).toMatchObject({ tag: '사실오류', count: 2, example: '최신 예시' }); expect(p.tagCounts.find(t => t.tag === '형식오류')).toBeUndefined(); }); test('save → load 라운드트립 + 자기검토 블록 (2회 이상만)', () => { const brain = tmpBrain(); const p = computeWeaknessProfile([ CASE({ errorTag: '사실오류' }), CASE({ errorTag: '사실오류' }), CASE({ errorTag: '근거누락' }), ], Date.now(), 60); expect(saveWeaknessProfile(brain, p)).toBe(true); const block = buildSelfReviewBlock(loadWeaknessProfile(brain)); expect(block).toContain('[자기검토'); expect(block).toContain('사실오류'); expect(block).not.toContain('근거누락'); // 1회짜리는 미주입 }); test('데이터 없으면 빈 블록', () => { expect(buildSelfReviewBlock(null)).toBe(''); expect(buildSelfReviewBlock(computeWeaknessProfile([], Date.now()))).toBe(''); }); }); describe('formatRegressionReport', () => { test('재발/통과/판정불가 마크', () => { const md = formatRegressionReport([ { question: 'q1', errorTag: '사실오류', repeated: true, note: 'n1' }, { question: 'q2', errorTag: '근거누락', repeated: false, note: 'n2' }, { question: 'q3', errorTag: '기타', repeated: null, note: 'n3' }, ], { dateStr: '2026-06-11' }); expect(md).toContain('❌ 재발'); expect(md).toContain('✅ 통과'); expect(md).toContain('⚠️ 판정불가'); }); });