diff --git a/app/engine/ocr_normalize.py b/app/engine/ocr_normalize.py new file mode 100644 index 0000000..59229c3 --- /dev/null +++ b/app/engine/ocr_normalize.py @@ -0,0 +1,168 @@ +"""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()} diff --git a/scripts/calibrate_ai_detector_cuts.py b/scripts/calibrate_ai_detector_cuts.py index 8af1ed0..cb84b57 100644 --- a/scripts/calibrate_ai_detector_cuts.py +++ b/scripts/calibrate_ai_detector_cuts.py @@ -11,7 +11,11 @@ 이 값은 여전히 **AI 작성 판정이 아니다.** 검토 우선순위 정렬용이다. -입력: 영속 코퍼스 SQLite (기본) 또는 data/reference/*.txt +입력: 영속 코퍼스 SQLite (기본) / data/reference/*.txt / 에피소드 xlsx + + OCR 후처리 코퍼스가 입력이면 --ocr-normalize (기본 켜짐) 로 조판·스캔 흔적을 + 먼저 지운다. 그러지 않으면 컷이 저자 문체가 아니라 그 책의 조판 관습 위에서 + 잡히고, 다른 조판의 원고에는 맞지 않는다. 출력: JSON 리포트 + 그대로 붙여넣을 .env 두 줄 사용: @@ -34,6 +38,10 @@ sys.path.insert(0, str(ROOT)) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") logger = logging.getLogger("calibrate-cuts") +from app.engine.ocr_normalize import ( # noqa: E402 + normalize_ocr, + ocr_artifact_stats, +) from app.engine.ai_detector import ( # noqa: E402 DEFAULT_HIGH_PERCENTILE, DEFAULT_LOW_PERCENTILE, @@ -49,11 +57,42 @@ def _stable_pick(key: str) -> int: return int(hashlib.sha1(key.encode("utf-8")).hexdigest()[:8], 16) -def load_texts(database: Path | None, reference_dir: Path | None, sample: int) -> list[str]: +def load_texts( + database: Path | None, + reference_dir: Path | None, + sample: int, + xlsx: Path | None = None, + text_column: str = "에피소드", + book_column: str = "book_name", +) -> list[str]: """코퍼스에서 텍스트를 표본 추출. 전량 처리는 느리므로 기본은 표본이다.""" items: list[tuple[int, str]] = [] - if database and database.exists(): + if xlsx and xlsx.exists(): + import pandas as pd + + frame = pd.read_excel(xlsx, sheet_name=0) + if text_column not in frame.columns: + raise SystemExit( + f"'{text_column}' 컬럼이 없습니다. 있는 컬럼: {list(frame.columns)}" + ) + # 표본 키에 도서명을 섞어야 한 책이 표본을 독점하지 않는다. + has_book = book_column in frame.columns + seen: set[str] = set() + for idx in range(len(frame)): + text = frame[text_column].iloc[idx] + if not isinstance(text, str) or not text.strip(): + continue + norm_key = " ".join(text.split()) + if norm_key in seen: # 완전 중복 에피소드 제거 (34,105 → 31,560) + continue + seen.add(norm_key) + book = str(frame[book_column].iloc[idx]) if has_book else "" + items.append((_stable_pick(f"{book}:{idx}"), text)) + logger.info( + "xlsx 에피소드 %d건 로드(중복 제거 후): %s", len(items), xlsx.name + ) + elif database and database.exists(): from app.engine.provenance import CorpusStore for seg in CorpusStore(database).iter_segments(): @@ -79,6 +118,13 @@ def main() -> int: ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) + ap.add_argument("--xlsx", type=Path, default=None, + help="에피소드 xlsx (지정 시 최우선 입력)") + ap.add_argument("--text-column", default="에피소드") + ap.add_argument("--book-column", default="book_name", + help="표본을 도서에 고르게 퍼뜨리는 데 쓰는 그룹 컬럼") + ap.add_argument("--no-ocr-normalize", action="store_true", + help="OCR·조판 아티팩트 정규화를 끈다(기본은 켜짐)") ap.add_argument("--database", type=Path, default=Path("data/runtime/corpus.sqlite3")) ap.add_argument("--reference-dir", type=Path, default=Path("data/reference"), help="SQLite 가 없을 때 쓰는 폴백") @@ -90,11 +136,32 @@ def main() -> int: ap.add_argument("--out", type=Path, default=Path("data/models/ai_cuts.json")) args = ap.parse_args() - texts = load_texts(args.database, args.reference_dir, args.sample) + texts = load_texts( + args.database, args.reference_dir, args.sample, + xlsx=args.xlsx, text_column=args.text_column, book_column=args.book_column, + ) if not texts: logger.error("표본이 0건입니다.") return 2 + # OCR·조판 흔적을 먼저 지운다. 이걸 건너뛰면 컷이 저자 문체가 아니라 + # 그 책의 조판 관습 위에서 잡힌다. + ocr_before: dict[str, int] = {} + ocr_after: dict[str, int] = {} + if not args.no_ocr_normalize: + for text in texts: + for key, value in ocr_artifact_stats(text).items(): + ocr_before[key] = ocr_before.get(key, 0) + value + texts = [normalize_ocr(text) for text in texts] + for text in texts: + for key, value in ocr_artifact_stats(text).items(): + ocr_after[key] = ocr_after.get(key, 0) + value + removed = sum(ocr_before.values()) - sum(ocr_after.values()) + logger.info( + "OCR 정규화: 아티팩트 %d건 → %d건 (%d건 제거)", + sum(ocr_before.values()), sum(ocr_after.values()), removed, + ) + # 실제로 품사 특징이 쓰였는지 확인 (kiwipiepy 없으면 자동 폴백된다) from app.engine.ai_detector import extract_features @@ -143,9 +210,16 @@ def main() -> int: target_high = (100.0 - args.high_percentile) / 100.0 report = { - "source": str(args.database if args.database.exists() else args.reference_dir), + "source": str( + args.xlsx if args.xlsx and args.xlsx.exists() + else args.database if args.database.exists() + else args.reference_dir + ), "n_scored": len(scores), "n_skipped_short": skipped, + "ocr_normalized": not args.no_ocr_normalize, + "ocr_artifacts_before": ocr_before, + "ocr_artifacts_after": ocr_after, "use_pos_requested": not args.no_pos, "pos_actually_used": pos_used, "low_percentile": args.low_percentile, diff --git a/tests/test_ocr_normalize.py b/tests/test_ocr_normalize.py new file mode 100644 index 0000000..614d819 --- /dev/null +++ b/tests/test_ocr_normalize.py @@ -0,0 +1,122 @@ +"""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