5feacb4799
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>
109 lines
5.7 KiB
TypeScript
109 lines
5.7 KiB
TypeScript
/**
|
||
* 모델 출력 후처리 (post-stream sanitization). 모두 stateless pure transform.
|
||
*
|
||
* 1) sanitizeAssistantContent — 모델이 답변에 흘려보낸 내부 마커 (rationale,
|
||
* reasoning channels, Harmony/GPT-OSS 채널 마커, [PROBLEM]/[GOAL]/[REASONING]
|
||
* 메타 섹션) 를 제거한 *깨끗한 본문* 만 남긴다.
|
||
* 2) cleanDegeneratedOutput — 작은 모델이 long context 에서 degenerate 됐을 때
|
||
* (문자 벽, 영문 메타 코멘트, 중복 paragraph stutter 등) 가독성 복구.
|
||
* 모델 실패를 *고치는* 게 아니라 "나쁜 답변을 노이즈 벽이 아니라 읽을 수 있는
|
||
* 형태로 유지" 하는 정도의 책임.
|
||
* 3) isRestartedAnswer — continuation round 가 답변을 "이어붙이지" 않고 *처음부터*
|
||
* 다시 시작했는지 detect. 작은 모델이 흔히 "continue from here" 지시를 무시.
|
||
*
|
||
* Why one module: 셋 다 *모델 출력을 읽을 만한 텍스트로 정리* 라는 단일 책임을
|
||
* 공유. 호출 위치도 같은 stream loop 안에 모여 있어 응집도 높음.
|
||
*/
|
||
|
||
/**
|
||
* 모델 출력에서 *내부* 마커 / 메타 섹션을 제거. Harmony 스타일 채널 마커는
|
||
* `final` 채널만 남기고 나머지 (thought, analysis, commentary, reasoning) 는 통째로
|
||
* 제거 — 모델별 closing 형태가 달라 (`<channel|>`, `<|channel|>`, `<|end|>`,
|
||
* `<|return|>`) 보수적으로 매칭.
|
||
*/
|
||
export function sanitizeAssistantContent(text: string): string {
|
||
// [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델 조합에서 추론 텍스트가
|
||
// *여는 마커 없이* 답변 앞에 흐르고 이 END 마커로만 경계가 표시된다.
|
||
// 마지막 END 마커 이전은 전부 추론 → 폐기. 남은 마커 잔재도 제거.
|
||
let t = text;
|
||
const lsepEnd = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi;
|
||
const lsepParts = t.split(lsepEnd);
|
||
if (lsepParts.length > 1) t = lsepParts[lsepParts.length - 1];
|
||
t = t.replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, '');
|
||
|
||
const stripped = t
|
||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||
.replace(/(?:<think(?:ing)?>|<analysis>)[\s\S]*?(?:<\/think(?:ing)?>|<\/analysis>)/gi, '')
|
||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?<\|?channel\|?>/gi, '')
|
||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?(?=<\|?channel\|?>\s*final\b)/gi, '')
|
||
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
|
||
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
|
||
.trim();
|
||
return cleanDegeneratedOutput(stripped);
|
||
}
|
||
|
||
/**
|
||
* Long context 에서 small 모델이 degenerate 됐을 때의 blast radius 봉쇄:
|
||
* - 문자 벽 (`_______…`, `~~~~~~`) 8+ 반복은 모두 제거. markdown rule 은 3.
|
||
* - 영문 (Note: …) 류 자기 narration 제거.
|
||
* - leaked Chronicle / follow-up scaffolding 제거.
|
||
* - 중복 paragraph (모델 stutter) 합치기.
|
||
*
|
||
* 모델 실패를 *고치는* 게 아니라 "노이즈 벽" 을 "읽을 수 있는 텍스트" 로 유지.
|
||
*/
|
||
export function cleanDegeneratedOutput(text: string): string {
|
||
let s = text;
|
||
s = s.replace(/([_=~.*])\1{7,}/g, '');
|
||
s = s.replace(/\(Note:[^)]*\)/gi, '');
|
||
s = s.replace(/\n?Candidate records for this discussion[^\n]*/gi, '');
|
||
s = s.replace(/\(\s*질문\s*의도\s*[::][^)]*\)/g, '');
|
||
s = s.replace(/\[\s*핵심\s*확인\s*질문\s*\]\s*/g, '');
|
||
const paras = s.split(/\n{2,}/);
|
||
const deduped: string[] = [];
|
||
for (const p of paras) {
|
||
const norm = p.trim().replace(/\s+/g, ' ');
|
||
const prevNorm = deduped.length ? deduped[deduped.length - 1].trim().replace(/\s+/g, ' ') : '';
|
||
if (norm && norm === prevNorm) { continue; }
|
||
deduped.push(p);
|
||
}
|
||
return deduped.join('\n\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
/**
|
||
* Continuation round 가 "이어붙이기" 가 아니라 *처음부터 다시* 답변을 시작했는지
|
||
* detect — 공유 leading prefix 12자 이상 + 양쪽 다 40자 이상이면 restart 로 판정.
|
||
* 작은 모델이 "continue from here" 지시를 무시하는 흔한 회귀 대응.
|
||
*/
|
||
/**
|
||
* 모델 답변에 `<rationale>…</rationale>` 블록이 포함됐을 때 그 안의
|
||
* [PROBLEM] / [GOAL] / [REASONING] 세 섹션을 파싱해 구조화된 객체로 반환.
|
||
* 블록 자체가 없으면 undefined — 호출자가 옵셔널로 처리.
|
||
*
|
||
* 섹션 정규식은 `[PROBLEM]…[GOAL]…[REASONING]…` 순서 가정. 어느 한쪽이 빠지면
|
||
* 해당 필드는 빈 문자열 / fallback (reasoning 은 raw 전체) 로.
|
||
*/
|
||
export function parseRationale(text: string): { problem: string; goal: string; reasoning: string } | undefined {
|
||
const match = text.match(/<rationale>([\s\S]*?)<\/rationale>/);
|
||
if (!match) return undefined;
|
||
|
||
const raw = match[1];
|
||
const problem = raw.match(/\[PROBLEM\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
|
||
const goal = raw.match(/\[GOAL\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
|
||
const reasoning = raw.match(/\[REASONING\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || raw.trim();
|
||
|
||
return { problem, goal, reasoning };
|
||
}
|
||
|
||
export function isRestartedAnswer(soFar: string, cont: string): boolean {
|
||
const norm = (x: string) => x.trim().replace(/\s+/g, ' ').toLowerCase();
|
||
const a = norm(soFar);
|
||
const b = norm(cont);
|
||
if (a.length < 40 || b.length < 40) { return false; }
|
||
let i = 0;
|
||
const max = Math.min(a.length, b.length, 80);
|
||
while (i < max && a[i] === b[i]) { i++; }
|
||
return i >= 12;
|
||
}
|