Files
connectai/tests/investigationPipeline.test.ts
koriweb c79eda1862 v2.2.313~319: 보고 품질·조사 신뢰성 수술 + 네이버 쇼핑인사이트/검색어트렌드 연동
- 파트별 완성, 웹 실접속 증빙, 턴 의도 브리핑, 본문 불일치 검증, 하위 질문
  체크리스트, 사이트 심층 수집 (PATCHNOTES.md v2.2.313~318 참조)
- /naver-shopping, /naver-trend: 네이버 데이터랩 API 연동. compare/discover
  자동 판별, API 배치 상한 캡슐화, 카테고리 트리 fuzzy 매칭, 자격증명은
  Astra 설정 패널 SecretStorage 로 관리
2026-08-05 18:20:32 +09:00

218 lines
11 KiB
TypeScript

/**
* 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');
});
});
// [v2.2.312] 근거 없는 분석 감지 — 파일을 읽고도 인용이 없는 일반론 보고.
import { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } from '../src/intelligence/investigationPipeline';
describe('detectUngroundedAnalysis — 일반론 분석 감지', () => {
const generic = '이 프로젝트는 로컬 LLM과 RAG 기술의 결합 시도가 매우 인상적입니다. '.repeat(10);
test('read 를 하고도 파일 인용 없는 장문 = ungrounded', () => {
const r = detectUngroundedAnalysis(generic, { reads: 3, lists: 1, investigates: 0 });
expect(r.ungrounded).toBe(true);
expect(r.readCount).toBe(3);
});
test('파일 근거를 2개 이상 인용하면 정상', () => {
const grounded = `${generic} 근거: agent.ts 의 handlePrompt 와 memoryContext.ts 의 buildMemoryContext 를 확인함.`;
expect(detectUngroundedAnalysis(grounded, { reads: 3, lists: 1, investigates: 0 }).ungrounded).toBe(false);
});
test('read 가 없으면 판정 대상 아님 (hollow 감지의 영역)', () => {
expect(detectUngroundedAnalysis(generic, { reads: 0, lists: 1, investigates: 0 }).ungrounded).toBe(false);
});
test('짧은 확인형 응답은 오탐 방지로 제외', () => {
expect(detectUngroundedAnalysis('네, 서버 정상입니다.', { reads: 1, lists: 0, investigates: 0 }).ungrounded).toBe(false);
});
test('footer 에 읽은 횟수 포함', () => {
expect(formatUngroundedAnalysisFooter(3)).toContain('3회');
});
});
// [v2.2.313] 액션 0회 사각지대 — 파일을 아예 안 읽고 낸 장문 분석.
import { detectUnreadAnalysis, formatUnreadAnalysisFooter } from '../src/intelligence/investigationPipeline';
describe('detectUnreadAnalysis — 파일 미확인 분석 감지', () => {
const long = '이 프로젝트는 다층 메모리 구조를 갖춘 고도화된 시스템입니다. '.repeat(10);
test('로컬 경로 컨텍스트 + 액션 0회 + 장문 = 감지', () => {
expect(detectUnreadAnalysis(long, { reads: 0, lists: 0, investigates: 0 }, true)).toBe(true);
});
test('액션을 1회라도 썼으면 대상 아님 (hollow/ungrounded 영역)', () => {
expect(detectUnreadAnalysis(long, { reads: 1, lists: 0, investigates: 0 }, true)).toBe(false);
expect(detectUnreadAnalysis(long, { reads: 0, lists: 1, investigates: 0 }, true)).toBe(false);
});
test('로컬 경로 컨텍스트가 없으면 대상 아님', () => {
expect(detectUnreadAnalysis(long, { reads: 0, lists: 0, investigates: 0 }, false)).toBe(false);
});
test('짧은 답변은 오탐 방지로 제외', () => {
expect(detectUnreadAnalysis('짧은 확인 답변.', { reads: 0, lists: 0, investigates: 0 }, true)).toBe(false);
});
test('footer 에 재요청 안내 포함', () => {
expect(formatUnreadAnalysisFooter()).toContain('파일 미확인 분석');
});
});