학습·캘리브레이션에 쓰는 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>
123 lines
4.5 KiB
Python
123 lines
4.5 KiB
Python
"""ocr_normalize 단위테스트. 외부 의존 없음."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.engine.ocr_normalize import normalize_ocr, ocr_artifact_stats
|
|
|
|
|
|
class TestNumberUnitSpace:
|
|
"""규칙 2 — 없는 어절을 만들어내는 가장 흔한 아티팩트(35.9%)."""
|
|
|
|
def test_year_month(self):
|
|
assert normalize_ocr("1880 년 11 월에 쓴 편지") == "1880년 11월에 쓴 편지"
|
|
|
|
def test_various_units(self):
|
|
assert normalize_ocr("3 개월 뒤 5 명이 2 권을 샀다") == "3개월 뒤 5명이 2권을 샀다"
|
|
|
|
def test_latin_unit(self):
|
|
assert normalize_ocr("12 km 를 걸었다") == "12km 를 걸었다"
|
|
|
|
def test_eojeol_count_restored(self):
|
|
"""이 규칙의 목적 자체 — 어절 수가 정상으로 돌아와야 한다."""
|
|
assert len(normalize_ocr("1880 년 11 월").split()) == 2
|
|
|
|
def test_does_not_eat_real_words(self):
|
|
"""숫자 뒤 공백이라고 다 붙이면 안 된다. 단위 목록에 없으면 그대로."""
|
|
assert normalize_ocr("5 사람이 왔다") == "5 사람이 왔다"
|
|
assert normalize_ocr("3 번지 근처") == "3 번지 근처"
|
|
|
|
|
|
class TestHanjaGloss:
|
|
"""규칙 3 — 생성문에 없는 조판 관습(24.1%)."""
|
|
|
|
def test_removes_gloss(self):
|
|
assert normalize_ocr("대사(大使)의 임무를 띠고") == "대사의 임무를 띠고"
|
|
|
|
def test_keeps_latin_paren(self):
|
|
"""원어 병기는 본문일 수 있으므로 건드리지 않는다."""
|
|
text = '"탄식의 노래"(Das klagende Lied)'
|
|
assert "Das klagende Lied" in normalize_ocr(text)
|
|
|
|
def test_keeps_non_gloss_hanja(self):
|
|
"""앞말이 한글이 아니면 병기가 아니다 — 보수적으로 남긴다."""
|
|
assert "(大使)" in normalize_ocr("A (大使)")
|
|
|
|
def test_opt_out(self):
|
|
assert "(大使)" in normalize_ocr("대사(大使)", strip_hanja_gloss=False)
|
|
|
|
|
|
class TestQuotes:
|
|
"""규칙 4 — 낫표는 조판 관습이지 문체가 아니다(18.0%)."""
|
|
|
|
def test_bracket_quotes_unified(self):
|
|
assert normalize_ocr("「탄식의 노래」") == '"탄식의 노래"'
|
|
|
|
def test_curly_quotes_unified(self):
|
|
assert normalize_ocr("“인용” ‘강조’") == "\"인용\" '강조'"
|
|
|
|
def test_opt_out(self):
|
|
assert "「" in normalize_ocr("「제목」", unify_quotes=False)
|
|
|
|
|
|
class TestPunctSpacing:
|
|
def test_space_before_punct(self):
|
|
assert normalize_ocr("기본적 . 0루") == "기본적. 0루"
|
|
|
|
def test_inside_paren(self):
|
|
assert normalize_ocr("( 안쪽 )") == "(안쪽)"
|
|
|
|
def test_ellipsis(self):
|
|
assert normalize_ocr("그리고...") == "그리고…"
|
|
|
|
|
|
class TestWhitespace:
|
|
def test_zero_width_removed(self):
|
|
assert normalize_ocr("말러는") == "말러는"
|
|
|
|
def test_nbsp_to_space(self):
|
|
assert normalize_ocr("말러 는") == "말러 는"
|
|
|
|
def test_paragraphs_preserved(self):
|
|
assert normalize_ocr("문단1\n\n문단2") == "문단1\n\n문단2"
|
|
|
|
|
|
class TestInvariants:
|
|
"""이 두 성질이 깨지면 학습·캘리브레이션 결과를 신뢰할 수 없다."""
|
|
|
|
SAMPLES = [
|
|
"1880 년 11 월에 쓴 어떤 편지에는 대사(大使)의 「임무」가 적혀 있다...",
|
|
"말러는 한때 바그너에 심취한 채식주의자였다. 그때 그는 도덕적 작용을 경험하였다.",
|
|
"( 안쪽 ) 공백과 문장부호 . 그리고 3 개월",
|
|
"",
|
|
"짧은글",
|
|
]
|
|
|
|
@pytest.mark.parametrize("text", SAMPLES)
|
|
def test_idempotent(self, text):
|
|
once = normalize_ocr(text)
|
|
assert normalize_ocr(once) == once
|
|
|
|
def test_noop_on_clean_text(self):
|
|
"""운영에서 들어올 깨끗한 원고는 바뀌지 않아야 한다."""
|
|
clean = (
|
|
"말러는 한때 바그너에 심취한 채식주의자였다. 1880년 11월에 쓴 편지에는 "
|
|
'"동화극이 끝났다"고 적혀 있다.\n\n그것은 1년 이상 걸린 고심작이었다.'
|
|
)
|
|
assert normalize_ocr(clean) == clean
|
|
|
|
def test_empty(self):
|
|
assert normalize_ocr("") == ""
|
|
|
|
|
|
class TestStats:
|
|
def test_counts_before_and_after(self):
|
|
raw = "1880 년 대사(大使)의 「임무」... 그리고 3 개월"
|
|
before = ocr_artifact_stats(raw)
|
|
after = ocr_artifact_stats(normalize_ocr(raw))
|
|
assert before["num_unit_space"] > 0
|
|
assert before["hanja_gloss"] > 0
|
|
assert before["bracket_quote"] > 0
|
|
assert sum(after.values()) == 0
|