Files
connectai/src/lib/contextBuilders/outputSanitization.ts
T
koriweb 5feacb4799 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>
2026-07-14 18:18:49 +09:00

109 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 모델 출력 후처리 (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;
}