/** * Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309) * ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지 */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { isLocalInvestigationPrompt, buildInvestigationGateBlock, collectInvestigationFiles, runInvestigation, formatInvestigationNotes, detectHollowInvestigation, formatHollowInvestigationFooter, INVESTIGATE_CACHE_REL_DIR, MAX_INVESTIGATION_FILES, } from '../src/intelligence/investigationPipeline'; function tmpRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-inv-')); } describe('isLocalInvestigationPrompt — 조사형 요청 감지', () => { test('폴더/프로젝트 조사 요청 감지', () => { expect(isLocalInvestigationPrompt('E:\\Wiki\\Weekly 프로젝트 조사해줘')).toBe(true); expect(isLocalInvestigationPrompt('이 폴더의 문서들 분석해서 정리해줘')).toBe(true); expect(isLocalInvestigationPrompt('./docs 검토 부탁해')).toBe(true); }); test('로컬 대상이 없는 일반 요청은 미감지', () => { expect(isLocalInvestigationPrompt('오늘 날씨 어때?')).toBe(false); expect(isLocalInvestigationPrompt('삼성전자 주가 조사해줘')).toBe(false); // 로컬 자료 아님 }); test('증거 게이트 블록은 감지 시에만 생성', () => { expect(buildInvestigationGateBlock('이 폴더 코드 분석해줘')).toContain('investigate_files'); expect(buildInvestigationGateBlock('안녕')).toBe(''); }); }); describe('collectInvestigationFiles — 파일 수집', () => { test('텍스트 파일만, 결정적 순서, 제외 목록 보고', () => { const root = tmpRoot(); fs.mkdirSync(path.join(root, 'docs')); fs.writeFileSync(path.join(root, 'docs', 'b.md'), '내용B'); fs.writeFileSync(path.join(root, 'docs', 'a.md'), '내용A'); fs.writeFileSync(path.join(root, 'docs', 'img.png'), 'binary'); const { files, skipped } = collectInvestigationFiles(root, 'docs'); expect(files.map(f => f.relPath)).toEqual(['a.md', 'b.md']); expect(skipped.some(s => s.includes('img.png'))).toBe(true); }); test('상한 초과분은 skipped 로 (조용한 누락 금지)', () => { const root = tmpRoot(); fs.mkdirSync(path.join(root, 'many')); for (let i = 0; i < MAX_INVESTIGATION_FILES + 5; i++) { fs.writeFileSync(path.join(root, 'many', `f${String(i).padStart(3, '0')}.txt`), 'x' + i); } const { files, skipped } = collectInvestigationFiles(root, 'many'); expect(files).toHaveLength(MAX_INVESTIGATION_FILES); expect(skipped.filter(s => s.includes('상한 초과'))).toHaveLength(5); }); test('단일 파일 경로도 지원', () => { const root = tmpRoot(); fs.writeFileSync(path.join(root, 'one.md'), '단일'); const { files } = collectInvestigationFiles(root, 'one.md'); expect(files).toHaveLength(1); }); }); describe('runInvestigation — 강제 정독 + 캐시', () => { test('모든 파일에 LLM 추출 실행, 노트 생성', async () => { const root = tmpRoot(); fs.mkdirSync(path.join(root, 'p')); fs.writeFileSync(path.join(root, 'p', 'x.md'), 'X 파일 내용'); fs.writeFileSync(path.join(root, 'p', 'y.md'), 'Y 파일 내용'); const calls: string[] = []; const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async (_s, u) => { calls.push(u); return '목적: 테스트\n핵심:\n- 사실1'; }, }); expect(r.notes).toHaveLength(2); expect(r.llmCalls).toBe(2); expect(r.cacheHits).toBe(0); // LLM 입력에 실제 파일 내용이 들어감 (파일명만이 아니라) expect(calls[0]).toContain('X 파일 내용'); }); test('같은 내용 재조사 → 캐시 재사용 (LLM 0회) / 내용 변경 → 재추출', async () => { const root = tmpRoot(); fs.mkdirSync(path.join(root, 'p')); fs.writeFileSync(path.join(root, 'p', 'x.md'), '원본 내용'); const llm = jest.fn(async () => '목적: v1'); await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm }); expect(llm).toHaveBeenCalledTimes(1); // 2차: 동일 내용 → 캐시 const r2 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm }); expect(llm).toHaveBeenCalledTimes(1); // 추가 호출 없음 expect(r2.cacheHits).toBe(1); expect(r2.notes[0].fromCache).toBe(true); expect(fs.existsSync(path.join(root, INVESTIGATE_CACHE_REL_DIR))).toBe(true); // 3차: 내용 변경 → 재추출 fs.writeFileSync(path.join(root, 'p', 'x.md'), '변경된 내용'); const r3 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm }); expect(llm).toHaveBeenCalledTimes(2); expect(r3.cacheHits).toBe(0); }); test('추출 실패 파일은 skipped 로 계속 진행', async () => { const root = tmpRoot(); fs.mkdirSync(path.join(root, 'p')); fs.writeFileSync(path.join(root, 'p', 'ok.md'), '정상'); fs.writeFileSync(path.join(root, 'p', 'zz.md'), '실패 대상'); const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async (_s, u) => { if (u.includes('실패 대상')) throw new Error('LLM down'); return '목적: ok'; }, }); expect(r.notes).toHaveLength(1); expect(r.skipped.some(s => s.includes('zz.md') && s.includes('추출 실패'))).toBe(true); }); test('병합 노트에 파일 경로·미확인 목록 포함', async () => { const root = tmpRoot(); fs.mkdirSync(path.join(root, 'p')); fs.writeFileSync(path.join(root, 'p', 'a.md'), 'A'); fs.writeFileSync(path.join(root, 'p', 'bin.png'), 'x'); const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async () => '목적: A' }); const notes = formatInvestigationNotes(r, '구조 파악'); expect(notes).toContain('[a.md]'); expect(notes).toContain('조사 목적: 구조 파악'); expect(notes).toContain('미확인 파일'); expect(notes).toContain('실제로 읽고'); }); }); describe('detectHollowInvestigation — 헛조사 감지', () => { const answer = '이 프로젝트는 server.mjs가 백엔드이고 dashboard.html이 화면입니다. config.json은 설정입니다.'; test('list만 하고 read 0 + 파일 2개 이상 서술 = 헛조사', () => { const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 }); expect(r.hollow).toBe(true); expect(r.fileMentions.length).toBeGreaterThanOrEqual(2); }); test('실제로 읽었으면(read>0) 정상', () => { expect(detectHollowInvestigation(answer, { reads: 2, lists: 1, investigates: 0 }).hollow).toBe(false); }); test('investigate_files 를 썼으면 정상', () => { expect(detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 1 }).hollow).toBe(false); }); test('list 자체가 없으면 판정 대상 아님', () => { expect(detectHollowInvestigation(answer, { reads: 0, lists: 0, investigates: 0 }).hollow).toBe(false); expect(detectHollowInvestigation(answer, null).hollow).toBe(false); }); test('파일 언급 1개는 오탐 방지 위해 통과', () => { const one = 'README.md 를 확인해보세요.'; expect(detectHollowInvestigation(one, { reads: 0, lists: 1, investigates: 0 }).hollow).toBe(false); }); test('경고 footer 에 파일명 포함', () => { const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 }); const footer = formatHollowInvestigationFooter(r.fileMentions); expect(footer).toContain('헛조사 감지'); expect(footer).toContain('investigate_files'); }); });