fix(output): 한·영 깨진 토큰("덩ey") 결정론 감지 + 1회 수리 패스 (v2.2.230)

소형 로컬 모델이 한국어 단어 중간에 영문 토큰을 섞는 디코딩 사고
("덩어리"→"덩ey", "결과적으로"→"결ently"). 프롬프트 출력 위생 규칙으로는
못 막음 — 지시 불이행이 아니라 토큰 붕괴라서. 사후 보정으로 해결:

- hangulHygiene.ts: 고정밀 감지 패턴(한글 음절+영문 소문자 2+ 연속) —
  "API를"/"Code의"(영문+조사)·"플랜B"(한글+대문자)는 정상 표기로 미감지,
  코드 블록 제외. 감지 시 LLM 수리 1회 (깨진 토큰만 복원, 내용 변경 금지).
- 수리 검증 게이트: 길이 ±35% 이내 + 깨진 토큰 감소 — 미통과 시 원문 유지
  (수리가 더 망치는 것 방지). 실패 전 과정 로그 (관측성 원칙).
- 적용 경로: 채팅 답변(스트림 후·확정 전) + /wikify 산출물(영구 자산이라
  더 중요 — "🩹 표기 오류 N건 교정" 표시).

테스트 11건 (감지 정밀도·검증 게이트·실패 안전).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:50:26 +09:00
parent bfb0d23a2f
commit 1208050557
6 changed files with 158 additions and 3 deletions
+29
View File
@@ -1092,6 +1092,35 @@ export class AgentExecutor {
continuationCount = _cont.continuationCount;
if (this.isStaleRun(runId)) return;
}
// (c2) 한·영 깨진 토큰 수리 — "덩어리"→"덩ey" 류 토큰 붕괴를 결정론 감지
// 후 1회 수리 패스로 복원. 검증 미통과 시 원문 유지 (악화 방지).
if (loopDepth === 0 && cleaned.visible && !this.abortController?.signal.aborted) {
try {
const { findBrokenHangulTokens, repairBrokenHangul } = await import('./agent/hangulHygiene');
const broken = findBrokenHangulTokens(cleaned.visible);
if (broken.length > 0) {
this.webview.postMessage({ type: 'autoContinue', value: '표기 오류 교정 중…' });
const repaired = await repairBrokenHangul(cleaned.visible, broken, async (system, user, maxTokens) => {
const r = await this.callNonStreaming({
baseUrl: ollamaUrl, modelName: actualModel, engine,
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
temperature: 0.1, maxTokens, contextLength: ctxLimits.contextLength,
signal: this.abortController?.signal,
});
return r.text;
});
if (repaired) {
logInfo('한·영 깨진 토큰 수리 완료.', { broken: broken.slice(0, 5), before: cleaned.visible.length, after: repaired.length });
cleaned = { ...cleaned, visible: repaired };
} else {
logInfo('한·영 깨진 토큰 감지 — 수리 검증 미통과, 원문 유지.', { broken: broken.slice(0, 5) });
}
}
} catch (e: any) {
logError('한글 위생 수리 실패 (원문 유지).', { error: e?.message ?? String(e) });
}
}
// 답변 sanitize / policy enforcement → src/agent/handlePrompt/processFinalAnswer.ts
const _finalProc = processFinalAnswer({
visibleAnswer: cleaned.visible,
+61
View File
@@ -0,0 +1,61 @@
/**
* 한·영 깨진 토큰 감지·수리 — 소형 로컬 모델의 토큰 붕괴 보정.
*
* 증상: 한국어 단어 중간에 영문 토큰이 섞임 — "덩어리"→"덩ey", "결과적으로"→"결ently".
* 프롬프트 규칙([출력 위생])으로는 못 막는다 — 지시 불이행이 아니라 디코딩 사고라서.
*
* 보정: 결정론 감지(아래 패턴) + 발견 시 1회 LLM 수리 패스.
* - 감지 패턴: 한글 음절 바로 뒤 영문 소문자 2+ ("덩ey" ✓). 방향이 중요 —
* 영문 단어 뒤 한글 조사("API를", "code의")는 정상 표기이므로 잡지 않는다.
* - 수리는 깨진 토큰 복원만 지시, 다른 내용 변경 금지. 수리 후 재검증
* (깨진 토큰이 줄고 길이 변화 ±35% 이내)을 통과해야 채택 — 아니면 원문 유지.
*/
const BROKEN_RE = /[가-힣][a-z]{2,}/g;
const MAX_REPAIR_CHARS = 14000;
/** 깨진 한·영 혼합 토큰 추출 (중복 제거, 주변 단어 포함). */
export function findBrokenHangulTokens(text: string): string[] {
if (!text) return [];
const out = new Set<string>();
// 코드 블록·인라인 코드는 제외 — 변수명에 한글+영문이 정당하게 섞일 수 있음.
const stripped = text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '');
for (const m of stripped.matchAll(BROKEN_RE)) {
// 매치 주변의 전체 단어를 보고용으로 수집.
const start = stripped.lastIndexOf(' ', m.index!) + 1;
const end = stripped.indexOf(' ', m.index!);
out.add(stripped.slice(start, end < 0 ? undefined : end).slice(0, 30));
if (out.size >= 10) break;
}
return Array.from(out);
}
export type RepairLlmCall = (system: string, user: string, maxTokens: number) => Promise<string>;
/**
* 깨진 토큰 수리 1회 — 성공 시 교정 전문, 실패/검증 미통과 시 null (원문 유지).
*/
export async function repairBrokenHangul(
text: string,
broken: string[],
callLlm: RepairLlmCall,
): Promise<string | null> {
if (!text.trim() || broken.length === 0 || text.length > MAX_REPAIR_CHARS) return null;
const system = [
'너는 한국어 표기 교정기다. 아래 텍스트에는 한국어 단어 중간에 영문 토큰이 깨져 들어간 오타가 있다 (예: "덩ey" → "덩어리", "결ently" → "결과적으로").',
'깨진 토큰만 문맥에 맞는 자연스러운 한국어로 복원하라. 그 외의 모든 내용·문장·마크다운 형식·코드는 한 글자도 바꾸지 마라.',
'교정된 전문만 출력하라 (해설·인사 금지).',
].join('\n');
const user = `[깨진 토큰 예시] ${broken.join(', ')}\n\n[텍스트]\n${text}`;
try {
const repaired = (await callLlm(system, user, Math.min(4000, Math.ceil(text.length / 2) + 500))).trim();
if (!repaired) return null;
// 검증 — 수리가 더 망치면 원문 유지.
const lenRatio = repaired.length / text.length;
if (lenRatio < 0.65 || lenRatio > 1.35) return null;
if (findBrokenHangulTokens(repaired).length >= broken.length) return null;
return repaired;
} catch {
return null;
}
}
+11
View File
@@ -445,6 +445,17 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef
report = await callLmSynthesis(buildWikifyPrompt(data, userContent, canonicalFormat), wikiSystem);
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]');
// 한·영 깨진 토큰("덩ey" 류) 감지 시 1회 수리 — 위키 문서는 영구 자산이라
// 채팅보다 더 중요. 검증 미통과면 원문 유지.
try {
const { findBrokenHangulTokens, repairBrokenHangul } = await import('../../agent/hangulHygiene');
const broken = findBrokenHangulTokens(report);
if (broken.length > 0) {
chunk(view, ` 🩹 표기 오류 ${broken.length}건 교정…`);
const fixed = await repairBrokenHangul(report, broken, (system, user) => callLmSynthesis(user, system));
if (fixed) report = fixed;
}
} catch { /* 수리 실패 시 원문 유지 */ }
chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`);
} catch (e: any) {
const reason = `LLM 합성 실패: ${e?.message || String(e)}`;