AI 생성 표본이 없어 정확도를 측정할 수 없는 상태에서, 규칙 기반 휴리스틱을 실제로 쓸 수 있게 만든다. 점수의 의미를 "AI일 확률"에서 "등록 코퍼스의 인간 저작물 대비 문체 이례도"로 재정의해, 라벨 없이도 참인 진술이 되도록 했다. 덤으로 high 배지 비율이 정의상 고정되어 검토 부하가 예측 가능해진다. - ai_detector: low_cut/high_cut 주입 지원. 둘 다 주어질 때만 적용하고, 적용되면 model_version 에 +corpus-percentile 을 붙여 컷 출처를 드러낸다. is_stub 은 여전히 true — 컷을 맞췄을 뿐 학습된 모델이 아니다. - calibrate_cuts/percentile/score_text_heuristic 순수 함수 추가. - scripts/calibrate_ai_detector_cuts.py: 코퍼스 표본의 점수 분포에서 백분위 컷을 산출하고 .env 두 줄을 출력한다. - 규칙 점수가 상단에서 clip 되어 p90=p98 이 되면 그 컷은 high 배지를 영원히 0건으로 만든다. 이 경우 .env 를 출력하지 않고 exit 3 으로 중단하며 saturated_share/distinct_scores 진단을 남긴다. 유효 표본 100건 미만은 exit 2. - kiwipiepy 유무가 점수를 바꾸므로 실제 사용 여부를 리포트에 기록하고 경고한다. 기본값은 그대로 비활성(AI_DETECTOR_ALLOW_HEURISTIC=false)이라 켜지 않으면 동작이 바뀌지 않는다. 컷 미설정 시에는 자리표시자임을 note 로 경고한다. 테스트 180건 통과 (신규 13건). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
8.6 KiB
Python
206 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""등록 코퍼스로 AI 의심도 컷을 백분위 캘리브레이션한다.
|
|
|
|
왜 이렇게 하나:
|
|
AI 생성 표본이 없으면 "AI일 확률"은 측정할 수 없다. 그래서 점수의 의미를
|
|
바꾼다. 등록 코퍼스는 전부 인간 저작이므로, 그 점수 분포의 상위 백분위를
|
|
컷으로 잡으면 결과는 **"우리 코퍼스의 인간 저작물 대비 얼마나 이례적인 문체인가"**
|
|
가 된다. 라벨도 지표도 필요 없고, "상위 2% 이례적 문체"라는 진술은 정확도를
|
|
측정하지 않아도 참이다. 덤으로 'high' 배지 비율이 정의상 고정되어 검토 부하가
|
|
예측 가능해진다.
|
|
|
|
이 값은 여전히 **AI 작성 판정이 아니다.** 검토 우선순위 정렬용이다.
|
|
|
|
입력: 영속 코퍼스 SQLite (기본) 또는 data/reference/*.txt
|
|
출력: 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.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) -> list[str]:
|
|
"""코퍼스에서 텍스트를 표본 추출. 전량 처리는 느리므로 기본은 표본이다."""
|
|
items: list[tuple[int, str]] = []
|
|
|
|
if 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("--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)
|
|
if not texts:
|
|
logger.error("표본이 0건입니다.")
|
|
return 2
|
|
|
|
# 실제로 품사 특징이 쓰였는지 확인 (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.database if args.database.exists() else args.reference_dir),
|
|
"n_scored": len(scores),
|
|
"n_skipped_short": skipped,
|
|
"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())
|