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