"""기호·숫자·단위 뒤 조사 선택 결정론 교정 — `josa_fix` rule_type 구현체. 한국어 조사(이/가·을/를·은/는·과/와·로/으로·였/이었)는 앞말의 **발음상** 받침 유무를 따르는데, 앞말이 숫자·라틴 대문자·단위 기호·닫는 괄호/따옴표로 끝나면 FT/LLM 이 일관되게 놓친다 (2026-09-17 chosun 풀셋 실측: gold 15건 중 모델 적중 1건 — 예: `WSJ은→는`, `91㎏로→으로`, `차화정)이→가`). 맞춤법 근거 두 가지가 anchor 결정을 가른다: - **괄호 안 삽입어는 읽지 않는다** → 조사는 괄호 *앞말*의 발음을 따른다 (`교섭단체(국민의힘)가`). 그래서 닫는 괄호를 만나면 여는 괄호 앞으로 건너뛴다. - **따옴표 안은 읽는다** → 따옴표 안 *마지막 글자*의 발음을 따른다 (`'MB.DRIVE'가`). 닫는 따옴표는 한 글자만 벗긴다. 보수 가드: 순수 한글 어절(따옴표·괄호를 안 거친 한글 anchor)은 LLM 영역이라 건드리지 않는다. 소문자 라틴(단어 독음 — iPhone=아이폰)과 R(-er 계 모음 독음)로 끝나는 anchor 도 독음이 갈려서 제외한다. """ from __future__ import annotations import re #: 닫는 따옴표류 — 이 문자는 발음되지 않으므로 벗기고 안쪽 글자를 anchor 로 삼는다. _CLOSING_QUOTES = "'\"’”′″´`" #: 교정 대상 조사 표면형 → (받침형, 무받침형). 로/으로는 ㄹ받침 예외를 별도 처리. _JOSA_PAIRS: dict[str, tuple[str, str]] = { "이": ("이", "가"), "가": ("이", "가"), "을": ("을", "를"), "를": ("을", "를"), "은": ("은", "는"), "는": ("은", "는"), "과": ("과", "와"), "와": ("과", "와"), "으로": ("으로", "로"), "로": ("으로", "로"), "이었던": ("이었던", "였던"), "였던": ("이었던", "였던"), "이었다": ("이었다", "였다"), "였다": ("이었다", "였다"), } #: 어절 = stem + 조사 + 꼬리 문장부호. 긴 표면형을 먼저 시도한다 (으로 > 로). _TOKEN_RE = re.compile( r"^(?P.+?)(?P이었던|였던|이었다|였다|으로|이|가|을|를|은|는|과|와|로)" r"(?P[.,!?;:·…]*)$" ) #: 숫자 독음의 받침 유무 (영일이삼사오육칠팔구) / ㄹ받침 숫자 (일·칠·팔). _DIGIT_BATCHIM = { "0": True, "1": True, "2": False, "3": True, "4": False, "5": False, "6": True, "7": True, "8": True, "9": False, } _DIGIT_RIEUL = frozenset("178") #: 라틴 대문자 낱자 독음에서 받침이 있는 것 (엘·엠·엔). R(알)은 단어 독음 #: (-er = 어)과 갈려서 아예 제외한다. _LATIN_BATCHIM = frozenset("LMN") #: 단위 기호 독음의 받침 유무 — 그램 계열(ㅁ)만 받침이 있다. ASCII 'g'/'m' 은 #: 영어 단어 꼬리(Dream 등)와 구분이 안 돼 넣지 않는다 — post_process 의 #: unit_unicode 가 kg→㎏ 변환을 이 스텝 앞에서 이미 해 준다. _UNIT_BATCHIM = { "㎏": True, "㎎": True, "㎍": True, "㎜": False, "㎝": False, "㎞": False, "㎖": False, "㎗": False, "ℓ": False, "㏄": False, "%": False, "℃": False, "℉": False, "㎡": False, "㎢": False, "㎟": False, "㎠": False, } _MAX_SKIPS = 8 # 중첩 괄호/따옴표 스킵 한도 — 비정상 입력 무한루프 방지 def _phoneme_of(ch: str, skipped: bool) -> tuple[bool, bool] | None: """anchor 글자의 (받침 유무, ㄹ받침 여부). 판정 불가/제외 대상이면 None.""" if "가" <= ch <= "힣": if not skipped: return None # 순수 한글 어절은 LLM 영역 — 괄호/따옴표를 거쳤을 때만 jong = (ord(ch) - 0xAC00) % 28 return (jong != 0, jong == 8) if ch.isdigit() and ch in _DIGIT_BATCHIM: return (_DIGIT_BATCHIM[ch], ch in _DIGIT_RIEUL) if "A" <= ch <= "Z": if ch == "R": return None return (ch in _LATIN_BATCHIM, False) if ch in _UNIT_BATCHIM: return (_UNIT_BATCHIM[ch], False) return None # 소문자 라틴(단어 독음), 미지 기호 def _resolve_anchor(line: str, stem_end: int) -> tuple[bool, bool] | None: """stem 끝에서 왼쪽으로 닫는 따옴표/괄호를 건너뛰며 발음 anchor 를 찾는다.""" i = stem_end - 1 skipped = False for _ in range(_MAX_SKIPS): while i >= 0 and line[i].isspace(): i -= 1 if i < 0: return None ch = line[i] if ch in _CLOSING_QUOTES: i -= 1 # 따옴표 안은 읽는다 — 한 글자만 벗긴다 skipped = True continue if ch in ")]": opener = "(" if ch == ")" else "[" depth, j = 1, i - 1 while j >= 0 and depth: if line[j] == ch: depth += 1 elif line[j] == opener: depth -= 1 j -= 1 if depth: return None # 짝 없는 괄호 — 판정 포기 i = j # 괄호 안은 읽지 않는다 — 여는 괄호 앞말로 skipped = True continue return _phoneme_of(ch, skipped) return None def _fixed_josa(josa: str, batchim: bool, rieul: bool) -> str: """anchor 발음에 맞는 조사 표면형. 로/으로는 ㄹ받침도 '로'를 취한다.""" with_b, without_b = _JOSA_PAIRS[josa] if with_b == "으로": return without_b if (not batchim or rieul) else with_b return with_b if batchim else without_b def apply_josa_fix(text: str) -> str: """`josa_fix` rule_type 구현 — 줄 단위로 어절을 훑어 조사를 교정한다.""" return "\n".join(_fix_line(line) for line in text.split("\n")) def _fix_line(line: str) -> str: edits: list[tuple[int, int, str]] = [] for m in re.finditer(r"\S+", line): tok = m.group() tm = _TOKEN_RE.match(tok) if not tm: continue josa = tm.group("josa") stem = tm.group("stem") anchor = _resolve_anchor(line, m.start() + len(stem)) if anchor is None: continue fixed = _fixed_josa(josa, *anchor) if fixed != josa: start = m.start() + len(stem) edits.append((start, start + len(josa), fixed)) for start, end, fixed in reversed(edits): line = line[:start] + fixed + line[end:] return line