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
+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('');
});
});