From 1208050557b5f5752506d32edc7bb6c1c33235f4 Mon Sep 17 00:00:00 2001 From: g1nation Date: Fri, 12 Jun 2026 13:50:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(output):=20=ED=95=9C=C2=B7=EC=98=81=20?= =?UTF-8?q?=EA=B9=A8=EC=A7=84=20=ED=86=A0=ED=81=B0("=EB=8D=A9ey")=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=EB=A1=A0=20=EA=B0=90=EC=A7=80=20+=201?= =?UTF-8?q?=ED=9A=8C=20=EC=88=98=EB=A6=AC=20=ED=8C=A8=EC=8A=A4=20(v2.2.230?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소형 로컬 모델이 한국어 단어 중간에 영문 토큰을 섞는 디코딩 사고 ("덩어리"→"덩ey", "결과적으로"→"결ently"). 프롬프트 출력 위생 규칙으로는 못 막음 — 지시 불이행이 아니라 토큰 붕괴라서. 사후 보정으로 해결: - hangulHygiene.ts: 고정밀 감지 패턴(한글 음절+영문 소문자 2+ 연속) — "API를"/"Code의"(영문+조사)·"플랜B"(한글+대문자)는 정상 표기로 미감지, 코드 블록 제외. 감지 시 LLM 수리 1회 (깨진 토큰만 복원, 내용 변경 금지). - 수리 검증 게이트: 길이 ±35% 이내 + 깨진 토큰 감소 — 미통과 시 원문 유지 (수리가 더 망치는 것 방지). 실패 전 과정 로그 (관측성 원칙). - 적용 경로: 채팅 답변(스트림 후·확정 전) + /wikify 산출물(영구 자산이라 더 중요 — "🩹 표기 오류 N건 교정" 표시). 테스트 11건 (감지 정밀도·검증 게이트·실패 안전). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 +- package.json | 2 +- src/agent.ts | 29 +++++++++++++ src/agent/hangulHygiene.ts | 61 ++++++++++++++++++++++++++++ src/features/datacollect/handlers.ts | 11 +++++ tests/hangulHygiene.test.ts | 54 ++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/agent/hangulHygiene.ts create mode 100644 tests/hangulHygiene.test.ts diff --git a/package-lock.json b/package-lock.json index e8a06d4..f8895b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.229", + "version": "2.2.230", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.229", + "version": "2.2.230", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index 48cc6ef..bde6bdd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.229", + "version": "2.2.230", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/agent.ts b/src/agent.ts index 0c54500..2d3f2ea 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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, diff --git a/src/agent/hangulHygiene.ts b/src/agent/hangulHygiene.ts new file mode 100644 index 0000000..ef4f99b --- /dev/null +++ b/src/agent/hangulHygiene.ts @@ -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(); + // 코드 블록·인라인 코드는 제외 — 변수명에 한글+영문이 정당하게 섞일 수 있음. + 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; + +/** + * 깨진 토큰 수리 1회 — 성공 시 교정 전문, 실패/검증 미통과 시 null (원문 유지). + */ +export async function repairBrokenHangul( + text: string, + broken: string[], + callLlm: RepairLlmCall, +): Promise { + 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; + } +} diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index d253e84..cf84233 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -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)}`; diff --git a/tests/hangulHygiene.test.ts b/tests/hangulHygiene.test.ts new file mode 100644 index 0000000..23e309f --- /dev/null +++ b/tests/hangulHygiene.test.ts @@ -0,0 +1,54 @@ +/** + * 한·영 깨진 토큰 감지·수리 — 순수 로직 테스트. + */ +import { findBrokenHangulTokens, repairBrokenHangul } from '../src/agent/hangulHygiene'; + +describe('findBrokenHangulTokens', () => { + test('깨진 토큰 감지: 한글+영문소문자 연속', () => { + const t = '문서를 단순히 텍스트 덩ey로 두지 말고, 결ently 헤더를 강제해야 합니다.'; + const broken = findBrokenHangulTokens(t); + expect(broken.some(b => b.includes('덩ey'))).toBe(true); + expect(broken.some(b => b.includes('결ently'))).toBe(true); + }); + test.each([ + 'API를 호출하고 Code의 구조를 본다', // 영문 단어 + 한글 조사 = 정상 + 'VS Code랑 LM Studio에서 모델을 로드한다', + '플랜B로 진행하고 옵션A를 검토한다', // 한글+대문자 1글자 = 정상 + 'RAG 파이프라인과 recall@1 지표', + ])('정상 표기는 미감지: %s', (t) => { + expect(findBrokenHangulTokens(t)).toHaveLength(0); + }); + test('코드 블록 안은 제외', () => { + const t = '설명\n```js\nconst 덩ey = 1;\n```\n그리고 `값ab` 도 변수다'; + expect(findBrokenHangulTokens(t)).toHaveLength(0); + }); +}); + +describe('repairBrokenHangul — 검증 게이트', () => { + const broken = ['덩ey로']; + const text = '텍스트 덩ey로 두지 말 것. '.repeat(3); + + test('수리 성공 (깨진 토큰 감소 + 길이 유지) → 채택', async () => { + const fixed = text.replace(/덩ey로/g, '덩어리로'); + const r = await repairBrokenHangul(text, broken, async () => fixed); + expect(r).toBe(fixed.trim()); + }); + test('수리 결과가 너무 짧으면 기각 (내용 유실 방지)', async () => { + const r = await repairBrokenHangul(text, broken, async () => '덩어리.'); + expect(r).toBeNull(); + }); + test('깨진 토큰이 줄지 않으면 기각', async () => { + const r = await repairBrokenHangul(text, broken, async () => text); + expect(r).toBeNull(); + }); + test('LLM 실패 시 null (원문 유지)', async () => { + const r = await repairBrokenHangul(text, broken, async () => { throw new Error('down'); }); + expect(r).toBeNull(); + }); + test('깨진 토큰 없으면 호출 자체를 안 함', async () => { + let called = false; + const r = await repairBrokenHangul(text, [], async () => { called = true; return text; }); + expect(r).toBeNull(); + expect(called).toBe(false); + }); +});