#!/usr/bin/env python3 """등록 코퍼스로 AI 의심도 컷을 백분위 캘리브레이션한다. 왜 이렇게 하나: AI 생성 표본이 없으면 "AI일 확률"은 측정할 수 없다. 그래서 점수의 의미를 바꾼다. 등록 코퍼스는 전부 인간 저작이므로, 그 점수 분포의 상위 백분위를 컷으로 잡으면 결과는 **"우리 코퍼스의 인간 저작물 대비 얼마나 이례적인 문체인가"** 가 된다. 라벨도 지표도 필요 없고, "상위 2% 이례적 문체"라는 진술은 정확도를 측정하지 않아도 참이다. 덤으로 'high' 배지 비율이 정의상 고정되어 검토 부하가 예측 가능해진다. 이 값은 여전히 **AI 작성 판정이 아니다.** 검토 우선순위 정렬용이다. 입력: 영속 코퍼스 SQLite (기본) / data/reference/*.txt / 에피소드 xlsx OCR 후처리 코퍼스가 입력이면 --ocr-normalize (기본 켜짐) 로 조판·스캔 흔적을 먼저 지운다. 그러지 않으면 컷이 저자 문체가 아니라 그 책의 조판 관습 위에서 잡히고, 다른 조판의 원고에는 맞지 않는다. 출력: JSON 리포트 + 그대로 붙여넣을 .env 두 줄 사용: python scripts/calibrate_ai_detector_cuts.py \ --database data/runtime/corpus.sqlite3 --sample 3000 """ from __future__ import annotations import argparse import hashlib import json import logging import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent 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, HARD_MIN_CHARS, calibrate_cuts, percentile, score_text_heuristic, ) 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, xlsx: Path | None = None, text_column: str = "에피소드", book_column: str = "book_name", ) -> list[str]: """코퍼스에서 텍스트를 표본 추출. 전량 처리는 느리므로 기본은 표본이다.""" items: list[tuple[int, str]] = [] 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(): items.append((_stable_pick(seg.segment_id), seg.text)) logger.info("SQLite 세그먼트 %d건 로드: %s", len(items), database) elif reference_dir and reference_dir.exists(): for path in sorted(reference_dir.glob("*.txt")): try: items.append((_stable_pick(path.name), path.read_text(encoding="utf-8"))) except UnicodeDecodeError: logger.warning("UTF-8 아님, 건너뜀: %s", path.name) logger.info("텍스트 파일 %d건 로드: %s", len(items), reference_dir) else: raise SystemExit("코퍼스를 찾을 수 없습니다. --database 또는 --reference-dir 확인.") items.sort(key=lambda kv: kv[0]) if sample > 0: items = items[:sample] return [text for _, text in items] 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 가 없을 때 쓰는 폴백") ap.add_argument("--sample", type=int, default=3000, help="표본 수 (0이면 전량). 3000이면 백분위 추정에 충분하다.") ap.add_argument("--low-percentile", type=float, default=DEFAULT_LOW_PERCENTILE) ap.add_argument("--high-percentile", type=float, default=DEFAULT_HIGH_PERCENTILE) ap.add_argument("--no-pos", action="store_true", help="품사 특징 사용 안 함") 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, 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 pos_used = ( not args.no_pos and extract_features(texts[0], use_pos=not args.no_pos)["pos_available"] >= 1.0 ) if not args.no_pos and not pos_used: logger.warning( "kiwipiepy 를 쓸 수 없어 품사 특징 없이 채점합니다. 운영 서버와 " "동일 조건이 아니면 컷이 맞지 않으니, 반드시 서버와 같은 환경에서 " "실행하세요." ) scores: list[float] = [] skipped = 0 for i, text in enumerate(texts, 1): value = score_text_heuristic(text, use_pos=not args.no_pos) if value is None: skipped += 1 continue scores.append(value) if i % 500 == 0: logger.info("채점 %d/%d", i, len(texts)) logger.info("채점 완료: %d건 (%d자 미만 %d건 제외)", len(scores), HARD_MIN_CHARS, skipped) if len(scores) < 100: logger.error( "유효 표본이 %d건뿐이라 백분위가 불안정합니다(최소 100). " "--sample 을 늘리거나 코퍼스를 확인하세요.", len(scores) ) return 2 low_cut, high_cut = calibrate_cuts(scores, args.low_percentile, args.high_percentile) distribution = {f"p{p}": round(percentile(scores, p), 4) for p in (5, 25, 50, 75, 90, 95, 98, 99)} expected_medium = sum(1 for s in scores if low_cut <= s < high_cut) / len(scores) expected_high = sum(1 for s in scores if s >= high_cut) / len(scores) # 포화 진단: 규칙 점수가 상단에서 clip 되면 p90=p98 이 되어 컷이 무의미해진다. # 이 경우 .env 를 그대로 쓰면 high 배지가 영원히 0건이 되므로 거부한다. top = max(scores) saturated_share = sum(1 for s in scores if s >= top - 1e-9) / len(scores) distinct = len(set(round(s, 4) for s in scores)) target_high = (100.0 - args.high_percentile) / 100.0 report = { "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, "high_percentile": args.high_percentile, "low_cut": low_cut, "high_cut": high_cut, "distribution": distribution, "distinct_scores": distinct, "saturated_share": round(saturated_share, 4), "expected_rate_on_human_corpus": { "medium": round(expected_medium, 4), "high": round(expected_high, 4), "target_high": target_high, }, "note": ( "인간 저작 코퍼스 대비 문체 이례도 컷이며 AI 작성 판정이 아니다. " "expected_rate 는 인간 원고에서도 이 비율만큼 medium/high 가 나온다는 뜻." ), } args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2)) if expected_high <= 0.0: logger.error( "이 컷을 쓰면 'high' 배지가 **영원히 0건**입니다. 점수가 상단에서 " "포화(최고점 동률 %.1f%%, 서로 다른 점수 %d개)되어 p%.0f 와 p%.0f 가 " "같은 값이기 때문입니다. .env 를 출력하지 않습니다.", saturated_share * 100, distinct, args.low_percentile, args.high_percentile, ) logger.error( "대응: ① --high-percentile 을 낮춰 동률 구간 아래로 컷을 내리거나 " "② --sample 을 늘려 분포를 넓히거나 ③ 표본이 특정 도서에 치우쳤는지 " "확인하세요. 리포트는 %s 에 저장했습니다.", args.out, ) return 3 if expected_high > target_high * 3 or expected_high < target_high / 3: logger.warning( "예상 high 비율 %.2f%% 가 목표 %.2f%% 에서 크게 벗어났습니다" "(점수 동률 때문). 배지 비율이 설계와 다르게 나옵니다.", expected_high * 100, target_high * 100, ) print("\n# .env 에 아래 두 줄을 추가하세요") print(f"AI_DETECTOR_LOW_CUT={low_cut}") print(f"AI_DETECTOR_HIGH_CUT={high_cut}") print( f"\n# 인간 원고 기준 예상 배지 비율: medium {expected_medium:.1%}, " f"high {expected_high:.1%}" ) return 0 if __name__ == "__main__": raise SystemExit(main())