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,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');
|
||||
});
|
||||
});
|
||||
@@ -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('일반 답변입니다.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {
|
||||
|
||||
Reference in New Issue
Block a user