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