Files
connectai/tests/investigationPipeline.test.ts
T
koriweb 5feacb4799 v2.2.308~310: Standing Rules + 조사 파이프라인 + LM Studio 추론 노출 수리
v2.2.308 — Standing Rules (Correction Loop ④): 행동/스타일 교정의 세션 영속화
- 행동 지적 감지("또 ~하네", "하지 말라고 했잖아" 등 5종) → LLM 이 명령형 규칙으로
  정규화 → .astra/growth/standing-rules.json 즉시 저장 → 매 턴 보호 구역 주입.
  self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하던 문제 해결.
- 중복 지적은 hits 증가(토큰 자카드), 상한 10개 초과 시 자동 은퇴, 재지적 시 부활.
- "Astra: 상시 행동 규칙" 커맨드 — 활성/은퇴 규칙 + 실제 주입 블록 열람(도달 증거).

v2.2.309 — 조사 파이프라인: 파일명만 보고 상상하는 헛조사 4중 차단
- <investigate_files path focus> 액션: 폴더의 텍스트 파일을 코드가 강제 정독해
  파일별 노트 주입(Map-Reduce). 내용 해시 캐시(.astra/cache/investigate)로 재조사
  시 재사용 — 온디맨드 지식화.
- 조사 증거 게이트(행동 제약) + Hollow Investigation 감지(list만 하고 read 0회로
  파일 서술 시 경고 footer — continuation 경로 포함) + actionStats turn 추적.
- g1nation.investigationModel: 조사형 요청만 상위 모델(예: claude-code:sonnet) 라우팅.

v2.2.310 — LM Studio LSEP 합성 추론 구분자 처리
- 일부 모델 조합에서 추론이 여는 마커 없이 content 앞에 흐르고
  __LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_<hex>__ 로만 경계 표시 →
  생각 과정 전체가 채팅에 노출되던 문제.
- LiveReasoningFilter: 마커 감지 시 streamReplace 신호로 화면 즉시 리셋(토큰 분절
  대응 holdback 포함), sanitizeAssistantContent: 마커 이전 추론 최종 제거(이중 방어).

기타: tests/wikiSave.test.ts 상대경로 테스트 Windows 호환 수정(드라이브 문자).
검증: 전체 테스트 926건 통과 (신규 39건: standingRules 15 + investigationPipeline 16 + LSEP 8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:18:49 +09:00

168 lines
8.1 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');
});
});