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>
This commit is contained in:
2026-07-14 18:18:49 +09:00
parent 137b83d8b6
commit 5feacb4799
17 changed files with 1098 additions and 16 deletions
+167
View File
@@ -0,0 +1,167 @@
/**
* 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');
});
});
+71
View File
@@ -63,3 +63,74 @@ describe('LiveReasoningFilter — 스트리밍 중 추론 구간 실시간 차
expect(run(['답변 끝 <'])).toBe('답변 끝 <');
});
});
describe('[v2.2.310] LM Studio LSEP 합성 추론 구분자', () => {
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
/** run + separator 신호 관찰 버전. */
function runWithSignal(tokens: string[]): { out: string; replacedAt: number[] } {
const f = new LiveReasoningFilter();
let out = '';
const replacedAt: number[] = [];
tokens.forEach((t, i) => {
const visible = f.push(t);
if (f.consumeSeparatorSignal()) {
out = visible; // streamReplace 시뮬레이션 — 화면 리셋
replacedAt.push(i);
} else {
out += visible;
}
});
out += f.flush();
return { out, replacedAt };
}
test('END 마커 뒤 텍스트만 최종 화면에 남는다 (실사례 재현)', () => {
const { out, replacedAt } = runWithSignal([
'User message: "안녕, 잘지내?" 분석... Option 1... Final choice...',
MARKER + '잘 지내고 있습니다. 별일 없으셨나요?',
]);
expect(out).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
expect(replacedAt).toEqual([1]); // 마커 도착 시점에 streamReplace 신호
});
test('마커가 토큰 경계에 잘게 쪼개져도 잡는다', () => {
const { out } = runWithSignal([
'추론 텍스트 어쩌고 ',
'__LM_STUDIO_INTERNAL_LSEP',
'_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c',
'9e5f3a7b8c1d2e6a__',
'실제 답변',
]);
expect(out).toBe('실제 답변');
});
test('START 변형이 오면 END 까지 통째로 숨긴다', () => {
const start = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_abc123def456__';
const { out } = runWithSignal(['답변 앞부분 ', start + '숨길 추론 ' + MARKER, '답변 뒷부분']);
expect(out).toBe('답변 앞부분 답변 뒷부분');
});
test('마커 없는 일반 밑줄 텍스트는 그대로 통과 (오탐 없음)', () => {
const { out, replacedAt } = runWithSignal(['snake_case_variable 와 __init__ 메서드', ' 설명입니다']);
expect(out).toBe('snake_case_variable 와 __init__ 메서드 설명입니다');
expect(replacedAt).toEqual([]);
});
});
describe('[v2.2.310] sanitizeAssistantContent — LSEP 최종 정리 (이중 방어)', () => {
const { sanitizeAssistantContent } = require('../src/lib/contextBuilders/outputSanitization');
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
test('마커 앞 추론 전체 제거, 답변만 남긴다', () => {
const raw = 'User message 분석... Option 1... Final choice...' + MARKER + '잘 지내고 있습니다. 별일 없으셨나요?';
expect(sanitizeAssistantContent(raw)).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
});
test('마커가 여러 번이면 마지막 마커 뒤만', () => {
const raw = '추론1' + MARKER + '중간' + MARKER + '최종 답변';
expect(sanitizeAssistantContent(raw)).toBe('최종 답변');
});
test('마커 없으면 기존 동작 그대로', () => {
expect(sanitizeAssistantContent('일반 답변입니다.')).toBe('일반 답변입니다.');
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* Standing Rules (Correction Loop ④) — 행동/스타일 교정의 세션 영속화. (v2.2.308)
*
* 배경: self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하는 문제.
* 행동 지적 → 명령형 규칙 파일 영속화 → 매 턴 주입으로 구조적으로 해결한다.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
looksLikeBehaviorComplaint,
loadStandingRules,
saveStandingRules,
upsertStandingRule,
buildStandingRulesBlock,
isSameRule,
MAX_ACTIVE_STANDING_RULES,
STANDING_RULES_REL_PATH,
StandingRule,
} from '../src/intelligence/correctionLoop';
function tmpBrain(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-sr-'));
}
describe('looksLikeBehaviorComplaint — 행동/스타일 지적 감지', () => {
test('반복 지적: "또 ~라고 썼네" (이번 이슈의 원 사례)', () => {
expect(looksLikeBehaviorComplaint('또 결론 수정이라고 썼네')).toBe(true);
});
test('"하지 말라고 했잖아"', () => {
expect(looksLikeBehaviorComplaint('결론 유지 문구 넣지 말라고 했잖아')).toBe(true);
});
test('"몇 번을 말해"', () => {
expect(looksLikeBehaviorComplaint('몇 번을 말해야 알아들어')).toBe(true);
});
test('"왜 자꾸 ~"', () => {
expect(looksLikeBehaviorComplaint('왜 자꾸 영어로 답해')).toBe(true);
});
test('출력 형식 지시: "붙이지 마"', () => {
expect(looksLikeBehaviorComplaint('출처 표기를 답변 중간에 붙이지 마')).toBe(true);
});
test('일반 질문은 오탐하지 않음', () => {
expect(looksLikeBehaviorComplaint('오늘 주가 어때?')).toBe(false);
expect(looksLikeBehaviorComplaint('회의록 요약해줘')).toBe(false);
// "또"로 시작하지만 지적이 아닌 추가 요청
expect(looksLikeBehaviorComplaint('또 다른 방법도 알려줘')).toBe(false);
});
test('빈/초장문 입력 방어', () => {
expect(looksLikeBehaviorComplaint('')).toBe(false);
expect(looksLikeBehaviorComplaint('또 ' + 'x'.repeat(2000))).toBe(false);
});
});
describe('isSameRule — 재지적 판정 (토큰 자카드)', () => {
test('같은 규칙의 표현 변형은 동일 판정', () => {
expect(isSameRule(
'답변에 결론 수정 문구를 붙이지 않는다',
'답변에 결론 수정 문구를 절대 붙이지 않는다',
)).toBe(true);
});
test('다른 규칙은 구분', () => {
expect(isSameRule(
'답변에 결론 수정 문구를 붙이지 않는다',
'모든 답변은 한국어로 작성한다',
)).toBe(false);
});
});
describe('upsertStandingRule — 저장/중복/상한', () => {
test('신규 규칙 저장 → 파일 생성 + active', () => {
const brain = tmpBrain();
const rules = upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '또 결론 수정이라고 썼네');
expect(rules).toHaveLength(1);
expect(rules[0].status).toBe('active');
expect(rules[0].hits).toBe(1);
expect(fs.existsSync(path.join(brain, STANDING_RULES_REL_PATH))).toBe(true);
// 라운드트립
const loaded = loadStandingRules(brain);
expect(loaded[0].rule).toBe('결론 수정 라벨을 붙이지 않는다');
});
test('같은 규칙 재지적 → hits 증가 (새 항목 추가 아님)', () => {
const brain = tmpBrain();
upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '지적1');
const rules = upsertStandingRule(brain, '결론 수정 라벨을 절대 붙이지 않는다', '지적2');
expect(rules).toHaveLength(1);
expect(rules[0].hits).toBe(2);
});
test('은퇴한 규칙이 재지적되면 부활', () => {
const brain = tmpBrain();
const retired: StandingRule[] = [{
id: 'sr-x', rule: '영어 단어를 남발하지 않는다', origin: 'o',
createdAt: '2026-01-01T00:00:00Z', lastHitAt: '2026-01-01T00:00:00Z', hits: 1, status: 'retired',
}];
saveStandingRules(brain, retired);
const rules = upsertStandingRule(brain, '영어 단어를 남발하지 않는다', '또 영어 남발이네');
expect(rules[0].status).toBe('active');
expect(rules[0].hits).toBe(2);
});
test('상한 초과 시 hits 낮고 오래된 규칙부터 은퇴', () => {
const brain = tmpBrain();
// 서로 유사도 판정(isSameRule)에 걸리지 않는 실제로 구분되는 규칙 10개
const distinct = [
'답변은 항상 한국어로 작성한다',
'수치를 말할 때 근거 문서를 인용한다',
'표를 남용하지 않는다',
'이모지 사용을 최소화한다',
'결론부터 먼저 말한다',
'추측일 때는 추정임을 명시한다',
'링크는 마크다운 형식으로 쓴다',
'제목에 물음표를 넣지 않는다',
'인사말을 매번 반복하지 않는다',
'코드 예시에는 설명 주석을 단다',
];
distinct.forEach((r, i) => upsertStandingRule(brain, r, `origin${i}`, `2026-01-0${(i % 9) + 1}T00:00:00Z`));
expect(loadStandingRules(brain)).toHaveLength(MAX_ACTIVE_STANDING_RULES);
// 규칙 하나에 hits 보강 (은퇴 대상에서 멀어지게)
upsertStandingRule(brain, '이모지 사용을 최소화한다', 'again', '2026-02-01T00:00:00Z');
// 상한+1 번째 신규 규칙
const rules = upsertStandingRule(brain, '긴 목록 대신 요약 문단을 우선한다', 'new', '2026-03-01T00:00:00Z');
const active = rules.filter(r => r.status === 'active');
expect(active).toHaveLength(MAX_ACTIVE_STANDING_RULES);
// hits 2인 규칙은 살아남고, 방금 들어온 신규도 active
expect(active.some(r => r.rule.includes('이모지'))).toBe(true);
expect(active.some(r => r.rule.includes('요약 문단'))).toBe(true);
expect(rules.some(r => r.status === 'retired')).toBe(true);
});
});
describe('buildStandingRulesBlock — 매 턴 주입 블록', () => {
test('active 규칙을 hits 내림차순으로 포함', () => {
const rules: StandingRule[] = [
{ id: 'a', rule: '규칙A', origin: '', createdAt: '', lastHitAt: '2026-01-01', hits: 1, status: 'active' },
{ id: 'b', rule: '규칙B', origin: '', createdAt: '', lastHitAt: '2026-01-02', hits: 3, status: 'active' },
{ id: 'c', rule: '규칙C', origin: '', createdAt: '', lastHitAt: '2026-01-03', hits: 1, status: 'retired' },
];
const block = buildStandingRulesBlock(rules);
expect(block).toContain('[상시 행동 규칙');
expect(block.indexOf('규칙B')).toBeLessThan(block.indexOf('규칙A')); // hits 순
expect(block).toContain('(지적 3회)');
expect(block).not.toContain('규칙C'); // retired 제외
});
test('규칙 없으면 빈 문자열 (프롬프트 오염 없음)', () => {
expect(buildStandingRulesBlock([])).toBe('');
expect(buildStandingRulesBlock([
{ id: 'c', rule: 'x', origin: '', createdAt: '', lastHitAt: '', hits: 1, status: 'retired' },
])).toBe('');
});
});
+2 -1
View File
@@ -50,7 +50,8 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => {
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
expect(r!.dir.replace(/\\/g, '/')).toBe('/Wiki/Premium/자료');
// Windows에서는 path.resolve가 드라이브 문자를 붙이므로(E:/Wiki/...) 끝부분만 검증
expect(r!.dir.replace(/\\/g, '/')).toMatch(/\/Wiki\/Premium\/자료$/);
});
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {