Files
connectai/tests/investigationPipeline.test.ts
T
koriweb 7d6b8b509f v2.2.311~312: 응답 지연 근본 개선(KV 캐시 분리·제2뇌 상주 캐시) + 보고 품질 수술
v2.2.311 — 응답 지연 (실측: 출력 22토큰에 94.7초, 원인은 매 턴 13k+ 토큰 전체 재프리필)
- KV 캐시 친화 프롬프트 분리(kvCachePromptSplit, 기본 ON): message[0]을 불변
  정적 본문으로 고정, 날짜/RAG/[CONTEXT]/동적 블록을 마지막 user 메시지 직전의
  internal system 메시지로 이동 — llama.cpp prompt cache 프리픽스 재사용으로
  턴당 재프리필을 "직전 교환 + 동적 컨텍스트"로 축소. truncation 도 tail 적용.
- 검색 토큰 예산 현실화(retrievalTokenBudget, 0=자동): 창의 25%(8k~80k) →
  12%(2.5k~6k 클램프).
- continuation 은 depth-0 memoryCtx 재사용: 라운드당 재검색 3~8초 제거 +
  빈 쿼리 재검색으로 청크가 갈리던 문제 제거 + 턴 내 프롬프트 안정화.
- 제2뇌 상주 캐시(신규 brainWatch.ts): 재귀 fs.watch 세대 카운터로 변경 없으면
  디렉터리 워크·파일별 statSync 전면 생략. 수정 직후 3초 창은 신뢰 제외(이벤트
  지연 레이스 가드), 워처 불가 시 종전 폴백, 활성화 시 백그라운드 워밍,
  유휴 해제 30분→2시간.

v2.2.312 — 보고 품질 (실사례: "## 4."부터 시작하는 7줄 일반론 보고서)
- 중간 라운드 본문 표시 버그 수정: 액션과 함께 작성된 섹션(1~3)이 화면에 한 번도
  안 나가고 최종 라운드만 표시되던 근본 원인 제거 — stripForDisplay.ts 로 액션
  태그만 걷어내고 라운드 순서대로 버블에 표시.
- '분석 보고' 업무 유형 신설(requirementGraph): 보고 개요(첫 줄 자기선언)·파일
  근거(주장마다 실제 읽은 파일 인용, 일반론 금지)·구조·발견·다음 단계 강제,
  유형 감지 시 "최대 3섹션" 규칙보다 필수 요소 커버 우선.
- 근거 없는 분석 감지: 파일을 읽고도 인용 2개 미만인 장문 분석에 "근거 인용
  없음" footer 경고 (헛조사 감지의 반대 방향).

검증: 전체 테스트 954건 통과(신규 28), tsc 무오류, vsix 패키징·설치 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:21:41 +09:00

194 lines
9.6 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회');
});
});