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:
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user