o2o-plagiarism-ai/app/engine/ocr_normalize.py
hbyang 45355910fa feat: OCR·조판 아티팩트 정규화와 백분위 컷 캘리브레이션
학습·캘리브레이션에 쓰는 79권 코퍼스는 OCR 후처리본이라 저자 문체가 아닌
스캔·조판 흔적이 남아 있다. AI 생성 표본에는 그 흔적이 없으므로, 그대로 두면
분류기가 문체가 아니라 "OCR 흔적이 있으면 human"을 배운다. 그러면 운영에서
들어오는 깨끗한 인간 원고가 AI로 오판된다.

규칙은 추측이 아니라 코퍼스 34,105건 실측에서 뽑았다. 표본 3,000건 기준
숫자+공백+단위 94.8%, 한자 병기 99.4%, 낫표 100% 제거.
정상 한국어인 `America라는`·`3년` 형태는 건드리지 않는다.

숫자-단위 규칙은 정규식으로 밀어넣으면 `3 번지`를 `3번지`로 잘못 붙이므로
단위·접미사 화이트리스트로 판정한다.

멱등성과 "깨끗한 입력에 무해" 두 불변식을 테스트로 고정했다. 후자가 깨지면
운영 원고가 학습 코퍼스와 다르게 처리되어 정규화 자체가 새 편향이 된다.

calibrate_ai_detector_cuts.py 에 xlsx 입력과 OCR 정규화(기본 켜짐)를 추가했다.
79권 3,000건 실측 결과 low_cut 0.4286 / high_cut 0.5298, 인간 원고 기준
예상 high 비율 2.03%(설계 목표 2%)로 포화 없이 잡혔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:53:59 +09:00

169 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()}