"""OCR·조판 아티팩트 정규화. 왜 필요한가: 학습·캘리브레이션에 쓰는 79권 코퍼스는 스캔 원본을 OCR 후처리한 결과다 (`json_path` 가 `..._OCR후처리_...`). 여기엔 저자 문체가 아니라 **스캔과 조판에서 생긴 흔적**이 남아 있고, AI 생성 표본에는 그 흔적이 없다. 이대로 두면 분류기는 문체가 아니라 "OCR 흔적이 있으면 human" 을 배운다. 그러면 정작 운영에서 들어오는 깨끗한 인간 원고가 AI 로 오판된다. 휴리스틱 백분위 캘리브레이션도 같은 문제를 겪는다 — 컷이 조판 관습 위에서 잡혀 다른 조판의 원고에 맞지 않는다. 특히 `숫자 + 공백 + 단위` (1880 년) 는 없는 어절을 하나 만들어내므로 `mean_eojeol_len` / `cv_eojeol_len` 같은 띄어쓰기 특징을 직접 오염시킨다. 규칙은 추측이 아니라 코퍼스 34,105건 실측에서 나왔다. 괄호 안 비율은 해당 패턴을 포함한 문서 비율이다. 1. 유니코드 공백/제어문자 통일 (1.1%) 2. 숫자–단위 사이 공백 제거 — `1880 년` → `1880년` (35.9%) 3. 한자 병기 괄호 제거 — `대사(大使)` → `대사` (24.1%) 4. 인용부호 글리프 통일 — `「」『』“”` → `"` (18.0%) 5. 문장부호 앞 공백 제거 — `기본적 . 0루` → `기본적. 0루` (2.7%) 6. 괄호 안쪽 공백 정리 (1.0%) 7. 말줄임표 통일 — `...` → `…` (2.3%) 측정했지만 **고치지 않는 것**: - `America라는` 처럼 라틴+한글이 붙는 형태(3.5%)는 정상 한국어다. - `3년` 처럼 숫자+한글이 붙는 형태(23.2%)도 정상이다. 규칙 2가 만들려는 형태다. 성질: - **멱등** — `normalize_ocr(normalize_ocr(t)) == normalize_ocr(t)` - **깨끗한 입력에는 무해** — 아티팩트가 없으면 공백 정돈 외에 바뀌지 않는다 - 양쪽(human/AI)에 **똑같이** 적용해야 의미가 있다. 한쪽만 정규화하면 오염 방향만 뒤집힐 뿐이다. """ from __future__ import annotations import re import unicodedata __all__ = ["normalize_ocr", "ocr_artifact_stats", "ARTIFACT_PATTERNS"] # 1. 공백/제어 ------------------------------------------------------------- # NFKC 가 NBSP·전각공백은 처리하지만 zero-width 계열은 남긴다. _ZERO_WIDTH = re.compile(r"[​‌‍⁠]") _ODD_SPACE = re.compile(r"[   -    ]") # 2. 숫자–단위 ------------------------------------------------------------- # 한글 단위 명사는 띄어쓰기가 맞춤법상 허용되지만, 생성문은 붙여 쓴다. # 목표는 맞춤법이 아니라 human/AI 양쪽 표기를 같은 형태로 모으는 것이다. # 단위 뒤에는 조사·접미사가 붙는 경우가 많다(11 월"에", 1880 년"부터", 20 세기). # 정규식 하나로 밀어넣으면 읽기 어렵고 `3 번지` 를 `3번지` 로 잘못 붙이므로, # 단위와 허용 접미사를 명시한 집합으로 판정한다. 어절 전체가 # `단위 + 허용접미사` 로 정확히 나뉠 때만 공백을 지운다. _UNIT_WORDS = frozenset({ "년대", "년", "월호", "월", "일자", "일", "시간", "시", "분", "초", "개월", "개", "명", "권", "회", "차", "세기", "세", "살", "번", "쪽", "면", "장", "편", "원", "달러", "위", "배", "퍼센트", "프로", "미터", "킬로", "톤", "호", "인분", "가지", # 수사 — `3 만` 처럼 끊긴 큰 수도 OCR 흔적이다 "만", "천", "백", "억", "조", }) _UNIT_SUFFIXES = frozenset({ "", "은", "는", "이", "가", "을", "를", "에", "의", "도", "만", "과", "와", "로", "여", "쯤", "경", "째", "생", "간", "발", "짜리", "에는", "에도", "에서", "부터", "까지", "이나", "이란", "이며", "이고", "간의", "간은", "간에", "에서는", "부터는", "까지는", "이라", "라는", "이었다", "였다", "이다", "이던", "인", "치", }) _NUM_THEN_HANGUL = re.compile(r"(?<=\d)[ \t]+([가-힣]+)") def _merge_num_unit(m: re.Match[str]) -> str: """어절이 단위+허용접미사로 정확히 나뉘면 앞 공백을 지운다.""" token = m.group(1) for unit in _UNIT_BY_LEN: if token.startswith(unit) and token[len(unit):] in _UNIT_SUFFIXES: return token return m.group(0) # 긴 단위를 먼저 봐야 `세기` 가 `세` 로 잘못 쪼개지지 않는다. _UNIT_BY_LEN = sorted(_UNIT_WORDS, key=len, reverse=True) # 숫자와 라틴 단위 (12 km, 30 cm) _NUM_UNIT_LATIN = re.compile(r"(?<=\d)\s+(?=(?:km|cm|mm|kg|mg|ml|m|g|%)(?![A-Za-z]))") # 3. 한자 병기 ------------------------------------------------------------- # 한글 뒤에 붙은 괄호 한자만 제거한다. 앞말이 한글이 아니면 병기가 아니라 # 본문일 수 있으므로 건드리지 않는다(보수적). _HANJA_GLOSS = re.compile(r"(?<=[가-힣])\s*\([一-鿿㐀-䶿]+\)") # 4. 인용부호 -------------------------------------------------------------- # 낫표는 원본 도서의 조판 관습이고 생성문은 큰따옴표를 쓴다. 글리프 선택은 # 문체가 아니므로 양쪽을 한 형태로 모아 신호에서 제거한다. _DQUOTE = re.compile(r"[「」『』“”〝〞"]") _SQUOTE = re.compile(r"[‘’‛']") # 5~7. 부호 주변 ------------------------------------------------------------ _SPACE_BEFORE_PUNCT = re.compile(r"[ \t]+(?=[.,!?;:%\)\]}])") _SPACE_AFTER_OPEN = re.compile(r"(?<=[(\[{])[ \t]+") _SPACE_BEFORE_CLOSE = re.compile(r"[ \t]+(?=[)\]}])") _ELLIPSIS = re.compile(r"\.{2,}|·{2,}|‥+") # 마무리 공백 정돈 _MULTI_SPACE = re.compile(r"[ \t]{2,}") _MULTI_NEWLINE = re.compile(r"\n{3,}") _TRAILING_WS = re.compile(r"[ \t]+\n") def normalize_ocr( text: str, *, strip_hanja_gloss: bool = True, unify_quotes: bool = True, ) -> str: """OCR·조판 아티팩트를 제거한 텍스트를 돌려준다. Args: strip_hanja_gloss: 한글 뒤 괄호 한자 병기를 제거한다. unify_quotes: 낫표·곡선따옴표를 직선따옴표로 통일한다. 두 플래그를 끄면 해당 규칙만 건너뛴다. 어떤 조합이든 멱등이다. """ if not text: return "" t = unicodedata.normalize("NFKC", text) t = t.replace("\r\n", "\n").replace("\r", "\n") t = _ZERO_WIDTH.sub("", t) t = _ODD_SPACE.sub(" ", t) t = _NUM_THEN_HANGUL.sub(_merge_num_unit, t) t = _NUM_UNIT_LATIN.sub("", t) if strip_hanja_gloss: t = _HANJA_GLOSS.sub("", t) if unify_quotes: t = _DQUOTE.sub('"', t) t = _SQUOTE.sub("'", t) t = _ELLIPSIS.sub("…", t) t = _SPACE_AFTER_OPEN.sub("", t) t = _SPACE_BEFORE_CLOSE.sub("", t) t = _SPACE_BEFORE_PUNCT.sub("", t) t = _MULTI_SPACE.sub(" ", t) t = _TRAILING_WS.sub("\n", t) t = _MULTI_NEWLINE.sub("\n\n", t) return t.strip() # 진단용 ------------------------------------------------------------------- ARTIFACT_PATTERNS: dict[str, re.Pattern[str]] = { "num_unit_space": re.compile(r"\d\s+[년월일시분초개명권회세살번차쪽원]"), "hanja_gloss": re.compile(r"[가-힣]\s*\([一-鿿]+\)"), "bracket_quote": re.compile(r"[「」『』]"), "space_before_punct": re.compile(r"\S[ \t]+[.,!?;:]"), "odd_space": re.compile(r"[  ​]"), "multi_dot": re.compile(r"\.{2,}"), } def ocr_artifact_stats(text: str) -> dict[str, int]: """텍스트에 남은 아티팩트를 패턴별로 센다. 정규화 전후 비교용.""" return {name: len(rx.findall(text)) for name, rx in ARTIFACT_PATTERNS.items()}