/** * 모델 출력 후처리 (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|>`, `<|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(/[\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(/(?:|)[\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" 지시를 무시하는 흔한 회귀 대응. */ /** * 모델 답변에 `` 블록이 포함됐을 때 그 안의 * [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(/([\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; }