o2o-plagiarism-ai/app/engine/ai_detector.py
hbyang a530d2139f AI 의심도 백분위 캘리브레이션 (학습·지표 없이 운영)
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>
2026-08-11 09:05:48 +09:00

1156 lines
43 KiB
Python
Raw 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.

"""한국어 AI 생성 의심도 — KatFishNet 계열 언어특징 기반 분류기.
⚠️ 본 모듈이 산출하는 값은 **AI 생성 여부의 확정 판정이 아니라, 사람 검토를
우선 배정하기 위한 "의심도"** 다. 자서전 도메인은 대필·윤문·편집자 개입이
일상적이라 정제된 문체가 AI로 오판되기 쉽고, 학습에 쓰이지 않은 생성 모델에는
일반화가 잘 되지 않으며, 가벼운 수정만으로도 회피된다. 저자에게 통보되는
판정 근거로 쓰지 말 것. 자세한 한계는 docs/AI_DETECTION.md 참조.
설계 원칙:
1) **결정적** — 같은 입력이면 항상 같은 출력. 난수·해시 더미 없음.
2) **설명 가능** — 점수의 근거가 되는 언어특징을 그대로 노출한다. 검토자가
"왜 의심되는가"를 확인할 수 없으면 이 기능은 쓸 수 없다.
3) **정직한 미가용** — 학습된 모델이 없으면 점수를 지어내지 않는다.
available=False 로 명시하거나, 미검증 휴리스틱임을 is_stub/model_version/
note 로 드러낸다.
특징 계열 (KatFishNet 계열 한국어 표지):
· 띄어쓰기 — 어절 길이 분포, 공백 비율
· 문장/쉼표 — 문장 길이 변동계수(burstiness), 쉼표 밀도, 종결어미 분포
· 품사 — 품사 다양도, 품사 n-gram 엔트로피/반복, 조사·어미 다양도
· 반복 — 문자 3-gram·어절 bigram 반복률, hapax 비율
· 길이 — 문자/어절/형태소 수
품사 특징은 kiwipiepy 가 있을 때만 산출된다. 미설치/오류 시에도 **특징 벡터의
길이와 순서는 변하지 않으며**(0.0 채움 + pos_available=0.0), 모델 아티팩트의
requires_pos 메타와 대조해 경고를 남긴다.
"""
from __future__ import annotations
import logging
import math
import os
import re
import unicodedata
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal, Sequence
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 상수
# ---------------------------------------------------------------------------
#: 이 길이 미만이면 점수를 내지 않는다. 짧은 글은 언어특징 통계가 무의미하다.
HARD_MIN_CHARS = 120
#: 이 길이 미만이면 점수는 내되 신뢰도 경고를 붙인다.
MIN_RELIABLE_CHARS = 300
#: 세그먼트(구간) 채점 시 목표 길이. 문단을 이 길이 이상으로 병합해 채점한다.
SEGMENT_TARGET_CHARS = 600
#: 기본 모델 경로. config.py 는 담당 범위 밖이라 환경변수로 읽는다.
#: 통합 시 Settings.ai_detector_model_path 로 승격할 것 (docs/AI_DETECTION.md).
ENV_MODEL_PATH = "AI_DETECTOR_MODEL_PATH"
DEFAULT_MODEL_PATH = "./data/models/ai_detector.joblib"
#: 잠정 의심도 구간. 학습 시 target FPR 기준으로 재산출되어 아티팩트에 저장되며,
#: 아티팩트 값이 있으면 그쪽이 우선한다. 아래는 아무 근거 없는 자리표시자이므로,
#: 휴리스틱 모드로 운영할 때는 scripts/calibrate_ai_detector_cuts.py 로 산출한
#: 백분위 컷을 설정으로 주입할 것.
DEFAULT_LOW_CUT = 0.40
DEFAULT_HIGH_CUT = 0.70
#: 백분위 캘리브레이션 기본값. 등록 코퍼스(전부 인간 저작) 대비 상위 10%를
#: medium, 상위 2%를 high 로 본다. "AI 확률"이 아니라 "인간 저작 대비 이례도"다.
DEFAULT_LOW_PERCENTILE = 90.0
DEFAULT_HIGH_PERCENTILE = 98.0
Provenance = Literal["human", "ai", "mixed", "edited", "unknown"]
SuspicionLevel = Literal["low", "medium", "high"]
# ---------------------------------------------------------------------------
# 특징 정의 — 순서가 곧 모델 입력 벡터의 순서다. 절대 중간 삽입/삭제 금지.
# 변경이 필요하면 FEATURE_SET_VERSION 을 올리고 모델을 재학습할 것.
# ---------------------------------------------------------------------------
FEATURE_SET_VERSION = "kf-ko-v1"
_BASE_FEATURES: tuple[str, ...] = (
# 길이
"char_count",
"eojeol_count",
"sentence_count",
"paragraph_count",
# 띄어쓰기
"space_ratio",
"mean_eojeol_len",
"std_eojeol_len",
"cv_eojeol_len",
"long_eojeol_ratio",
"short_eojeol_ratio",
# 문장
"mean_sentence_len",
"std_sentence_len",
"cv_sentence_len",
"max_sentence_len",
"min_sentence_len",
"mean_paragraph_len",
# 쉼표·문장부호
"comma_per_sentence",
"comma_ratio",
"punct_ratio",
"punct_diversity",
"ellipsis_ratio",
"quote_ratio",
"exclaim_question_ratio",
# 종결
"end_da_ratio",
"end_yo_ratio",
"end_noun_ratio",
# 문자 구성
"hangul_ratio",
"digit_ratio",
"latin_ratio",
"newline_ratio",
# 반복·다양도
"eojeol_ttr",
"hapax_ratio",
"top1_token_share",
"top5_token_share",
"char3gram_repeat_ratio",
"word_bigram_repeat_ratio",
"distinct_word_bigram_ratio",
)
_POS_FEATURES: tuple[str, ...] = (
"pos_available",
"morph_count",
"mean_morphs_per_eojeol",
"content_word_ratio",
"pos_ttr",
"pos_bigram_entropy",
"pos_trigram_repeat_ratio",
"josa_ratio",
"josa_diversity",
"eomi_ratio",
"eomi_diversity",
"noun_ratio",
"verb_ratio",
"adj_ratio",
"adverb_ratio",
"conj_adverb_ratio",
"dependent_noun_ratio",
)
FEATURE_NAMES: tuple[str, ...] = _BASE_FEATURES + _POS_FEATURES
#: 길이 계열 특징. 문체가 아니라 '분량'을 학습할 위험이 있어 학습 시 제외할 수
#: 있다(train_ai_detector.py --drop-length-features). human 에피소드와 AI 생성물의
#: 길이 분포가 다르면, 모델이 문체 대신 길이만 보고 맞히는 착시가 생긴다.
LENGTH_FEATURES: tuple[str, ...] = (
"char_count",
"eojeol_count",
"sentence_count",
"paragraph_count",
"morph_count",
"max_sentence_len",
"min_sentence_len",
"mean_paragraph_len",
)
#: 사람이 읽을 수 있는 특징 설명 (검토 콘솔·문서용).
FEATURE_LABELS_KO: dict[str, str] = {
"cv_sentence_len": "문장 길이 변동계수(낮을수록 균일 = AI 경향)",
"cv_eojeol_len": "어절 길이 변동계수",
"comma_per_sentence": "문장당 쉼표 수",
"eojeol_ttr": "어절 다양도(TTR)",
"hapax_ratio": "1회 등장 어절 비율",
"pos_bigram_entropy": "품사 bigram 엔트로피(낮을수록 정형적)",
"pos_trigram_repeat_ratio": "품사 trigram 반복률",
"eomi_diversity": "어미 다양도",
"josa_diversity": "조사 다양도",
"char3gram_repeat_ratio": "문자 3-gram 반복률",
"word_bigram_repeat_ratio": "어절 bigram 반복률",
"end_da_ratio": "'-다' 종결 비율",
"end_yo_ratio": "'-요/-습니다' 종결 비율",
}
# ---------------------------------------------------------------------------
# 형태소 분석기 (선택적)
# ---------------------------------------------------------------------------
_kiwi_instance = None
_kiwi_failed = False
def _get_kiwi():
"""kiwipiepy 인스턴스. 미설치/초기화 실패 시 None 을 돌려주고 폴백한다."""
global _kiwi_instance, _kiwi_failed
if _kiwi_instance is not None:
return _kiwi_instance
if _kiwi_failed:
return None
try:
from kiwipiepy import Kiwi
_kiwi_instance = Kiwi()
return _kiwi_instance
except Exception as exc: # 미설치, 모델 파일 손상, 메모리 부족 등 전부 포함
logger.warning("kiwipiepy unavailable — POS features disabled: %s", exc)
_kiwi_failed = True
return None
def reset_kiwi_cache() -> None:
"""테스트용 — 형태소 분석기 캐시 초기화."""
global _kiwi_instance, _kiwi_failed
_kiwi_instance = None
_kiwi_failed = False
# ---------------------------------------------------------------------------
# 텍스트 분해
# ---------------------------------------------------------------------------
_SENT_SPLIT = re.compile(r"(?<=[.!?。…])\s+|\n{1,}")
_PARA_SPLIT = re.compile(r"\n\s*\n")
_HANGUL = re.compile(r"[가-힣]")
_LATIN = re.compile(r"[A-Za-z]")
_DIGIT = re.compile(r"[0-9]")
_PUNCT = re.compile(r"[.,!?;:…·\"'“”‘’()\[\]{}—\-~/]")
def normalize_text(text: str) -> str:
"""비교 가능한 형태로 정규화. 원문 손실을 최소화한다(공백만 정돈)."""
if not text:
return ""
t = unicodedata.normalize("NFKC", text)
t = t.replace("\r\n", "\n").replace("\r", "\n")
t = re.sub(r"[ \t  ]+", " ", t)
t = re.sub(r"\n{3,}", "\n\n", t)
return t.strip()
def split_sentences(text: str) -> list[str]:
return [s.strip() for s in _SENT_SPLIT.split(text) if s and s.strip()]
def split_paragraphs(text: str) -> list[str]:
parts = [p.strip() for p in _PARA_SPLIT.split(text) if p and p.strip()]
if parts:
return parts
return [text.strip()] if text.strip() else []
def _mean(xs: Sequence[float]) -> float:
return sum(xs) / len(xs) if xs else 0.0
def _std(xs: Sequence[float]) -> float:
if len(xs) < 2:
return 0.0
m = _mean(xs)
return math.sqrt(sum((x - m) ** 2 for x in xs) / len(xs))
def _safe_div(a: float, b: float) -> float:
return a / b if b else 0.0
def _entropy(counts: Sequence[int]) -> float:
total = sum(counts)
if total <= 0:
return 0.0
h = 0.0
for c in counts:
if c <= 0:
continue
p = c / total
h -= p * math.log(p, 2)
return h
def _repeat_ratio(items: Sequence) -> float:
"""전체 중 '두 번 이상 등장한 항목이 차지하는 비율'."""
if not items:
return 0.0
cnt = Counter(items)
repeated = sum(c for c in cnt.values() if c > 1)
return repeated / len(items)
# ---------------------------------------------------------------------------
# 특징 추출
# ---------------------------------------------------------------------------
def extract_features(text: str, use_pos: bool = True) -> dict[str, float]:
"""raw 한국어 텍스트 → 결정적 언어특징 사전.
반환 키는 항상 FEATURE_NAMES 전체를 포함한다(값이 0.0 일지언정 누락 없음).
kiwipiepy 가 없으면 품사 특징은 0.0, pos_available=0.0 이 된다.
"""
feats: dict[str, float] = {name: 0.0 for name in FEATURE_NAMES}
norm = normalize_text(text)
if not norm:
return feats
chars = len(norm)
eojeols = norm.split()
sentences = split_sentences(norm)
paragraphs = split_paragraphs(norm)
eojeol_lens = [len(e) for e in eojeols]
sent_lens = [len(s) for s in sentences]
feats["char_count"] = float(chars)
feats["eojeol_count"] = float(len(eojeols))
feats["sentence_count"] = float(len(sentences))
feats["paragraph_count"] = float(len(paragraphs))
# --- 띄어쓰기 ---
feats["space_ratio"] = _safe_div(norm.count(" "), chars)
mean_e = _mean(eojeol_lens)
std_e = _std(eojeol_lens)
feats["mean_eojeol_len"] = mean_e
feats["std_eojeol_len"] = std_e
feats["cv_eojeol_len"] = _safe_div(std_e, mean_e)
feats["long_eojeol_ratio"] = _safe_div(
sum(1 for n in eojeol_lens if n >= 8), len(eojeol_lens)
)
feats["short_eojeol_ratio"] = _safe_div(
sum(1 for n in eojeol_lens if n <= 2), len(eojeol_lens)
)
# --- 문장 ---
mean_s = _mean(sent_lens)
std_s = _std(sent_lens)
feats["mean_sentence_len"] = mean_s
feats["std_sentence_len"] = std_s
# burstiness 대용. AI 생성문은 문장 길이가 균일해 이 값이 낮은 경향.
feats["cv_sentence_len"] = _safe_div(std_s, mean_s)
feats["max_sentence_len"] = float(max(sent_lens)) if sent_lens else 0.0
feats["min_sentence_len"] = float(min(sent_lens)) if sent_lens else 0.0
feats["mean_paragraph_len"] = _mean([len(p) for p in paragraphs])
# --- 쉼표·문장부호 ---
commas = norm.count(",")
puncts = _PUNCT.findall(norm)
feats["comma_per_sentence"] = _safe_div(commas, len(sentences))
feats["comma_ratio"] = _safe_div(commas, chars)
feats["punct_ratio"] = _safe_div(len(puncts), chars)
feats["punct_diversity"] = _safe_div(len(set(puncts)), len(puncts))
feats["ellipsis_ratio"] = _safe_div(
norm.count("…") + len(re.findall(r"\.\.\.", norm)), max(1, len(sentences))
)
feats["quote_ratio"] = _safe_div(
len(re.findall(r"[\"“”'‘’]", norm)), chars
)
feats["exclaim_question_ratio"] = _safe_div(
norm.count("!") + norm.count("?"), max(1, len(sentences))
)
# --- 종결 형태 ---
if sentences:
da = yo = noun_end = 0
for s in sentences:
body = s.rstrip(".!?…\"'“”’ ")
if not body:
continue
if body.endswith(("습니다", "합니다", "요")):
yo += 1
elif body.endswith("다"):
da += 1
elif _HANGUL.search(body[-1:]):
noun_end += 1
feats["end_da_ratio"] = _safe_div(da, len(sentences))
feats["end_yo_ratio"] = _safe_div(yo, len(sentences))
feats["end_noun_ratio"] = _safe_div(noun_end, len(sentences))
# --- 문자 구성 ---
feats["hangul_ratio"] = _safe_div(len(_HANGUL.findall(norm)), chars)
feats["digit_ratio"] = _safe_div(len(_DIGIT.findall(norm)), chars)
feats["latin_ratio"] = _safe_div(len(_LATIN.findall(norm)), chars)
feats["newline_ratio"] = _safe_div(norm.count("\n"), chars)
# --- 반복·다양도 ---
if eojeols:
cnt = Counter(eojeols)
feats["eojeol_ttr"] = _safe_div(len(cnt), len(eojeols))
feats["hapax_ratio"] = _safe_div(
sum(1 for c in cnt.values() if c == 1), len(cnt)
)
ordered = cnt.most_common(5)
feats["top1_token_share"] = _safe_div(ordered[0][1], len(eojeols))
feats["top5_token_share"] = _safe_div(
sum(c for _, c in ordered), len(eojeols)
)
bigrams = [f"{a}␟{b}" for a, b in zip(eojeols, eojeols[1:])]
feats["word_bigram_repeat_ratio"] = _repeat_ratio(bigrams)
feats["distinct_word_bigram_ratio"] = _safe_div(
len(set(bigrams)), len(bigrams)
)
compact = re.sub(r"\s+", "", norm)
if len(compact) >= 3:
trigrams = [compact[i : i + 3] for i in range(len(compact) - 2)]
feats["char3gram_repeat_ratio"] = _repeat_ratio(trigrams)
# --- 품사 ---
if use_pos:
pos_feats = _extract_pos_features(norm, len(eojeols))
if pos_feats:
feats.update(pos_feats)
return feats
def _extract_pos_features(norm: str, eojeol_count: int) -> dict[str, float] | None:
"""kiwipiepy 기반 품사 특징. 사용 불가면 None (호출자가 0.0 유지)."""
kiwi = _get_kiwi()
if kiwi is None:
return None
try:
tokens = kiwi.tokenize(norm)
except Exception as exc:
logger.warning("kiwi tokenize failed — POS features skipped: %s", exc)
return None
if not tokens:
return None
tags = [t.tag for t in tokens]
n = len(tags)
out: dict[str, float] = {
"pos_available": 1.0,
"morph_count": float(n),
"mean_morphs_per_eojeol": _safe_div(n, eojeol_count),
}
tag_counts = Counter(tags)
out["pos_ttr"] = _safe_div(len(tag_counts), n)
bigrams = [f"{a}_{b}" for a, b in zip(tags, tags[1:])]
out["pos_bigram_entropy"] = _entropy(list(Counter(bigrams).values()))
trigrams = [f"{a}_{b}_{c}" for a, b, c in zip(tags, tags[1:], tags[2:])]
out["pos_trigram_repeat_ratio"] = _repeat_ratio(trigrams)
def _ratio(prefixes: tuple[str, ...]) -> float:
return _safe_div(
sum(c for tag, c in tag_counts.items() if tag.startswith(prefixes)), n
)
def _diversity(prefixes: tuple[str, ...]) -> float:
"""해당 계열 안에서 서로 다른 표층형이 얼마나 다양한가."""
forms = {t.form for t in tokens if t.tag.startswith(prefixes)}
total = sum(1 for t in tokens if t.tag.startswith(prefixes))
return _safe_div(len(forms), total)
content = ("NNG", "NNP", "VV", "VA", "MAG")
out["content_word_ratio"] = _ratio(content)
out["josa_ratio"] = _ratio(("JK", "JX", "JC"))
out["josa_diversity"] = _diversity(("JK", "JX", "JC"))
out["eomi_ratio"] = _ratio(("EF", "EC", "EP", "ETN", "ETM"))
out["eomi_diversity"] = _diversity(("EF", "EC", "EP", "ETN", "ETM"))
out["noun_ratio"] = _ratio(("NNG", "NNP"))
out["verb_ratio"] = _ratio(("VV",))
out["adj_ratio"] = _ratio(("VA",))
out["adverb_ratio"] = _ratio(("MAG",))
out["conj_adverb_ratio"] = _ratio(("MAJ",))
out["dependent_noun_ratio"] = _ratio(("NNB",))
return out
def features_to_vector(
feats: dict[str, float], zeroed: Sequence[str] = ()
) -> list[float]:
"""FEATURE_NAMES 순서 그대로의 수치 벡터. 모델 입출력의 유일한 계약.
zeroed 에 든 특징은 0.0 으로 눌러 모델이 무시하도록 한다. 학습과 추론에서
**반드시 같은 목록**을 써야 하므로, 목록은 아티팩트에 저장되고 로드 시
복원된다. 벡터 길이는 어떤 경우에도 변하지 않는다.
"""
blocked = set(zeroed)
return [
0.0 if name in blocked else float(feats.get(name, 0.0))
for name in FEATURE_NAMES
]
# ---------------------------------------------------------------------------
# 결과 객체
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SegmentScore:
"""구간별 의심도. 문서 전체가 아니라 어느 부분이 의심되는지 보여준다."""
index: int
start: int
end: int
char_count: int
score: float | None
suspicion_level: SuspicionLevel | None
scored: bool
note: str = ""
preview: str = ""
@dataclass(frozen=True)
class AiDetectionResult:
"""AI 생성 의심도 결과.
⚠️ score 는 'AI가 썼을 확률'이 아니라 '사람 검토 우선순위'다. 확정 판정이
아니며, 저자 통보·계약 조치의 단독 근거로 사용해서는 안 된다.
"""
available: bool
score: float | None
suspicion_level: SuspicionLevel | None
provenance: Provenance
is_stub: bool
model_version: str
note: str
feature_set_version: str = FEATURE_SET_VERSION
pos_available: bool = False
char_count: int = 0
warnings: list[str] = field(default_factory=list)
segments: list[SegmentScore] = field(default_factory=list)
features: dict[str, float] = field(default_factory=dict)
top_contributions: list[tuple[str, float]] = field(default_factory=list)
def to_dict(self) -> dict:
"""API/로그 직렬화용. schemas.py 매핑은 docs/AI_DETECTION.md 참조."""
return {
"available": self.available,
"score": self.score,
"suspicion_level": self.suspicion_level,
"provenance": self.provenance,
"is_stub": self.is_stub,
"model_version": self.model_version,
"note": self.note,
"feature_set_version": self.feature_set_version,
"pos_available": self.pos_available,
"char_count": self.char_count,
"warnings": list(self.warnings),
"segments": [
{
"index": s.index,
"start": s.start,
"end": s.end,
"char_count": s.char_count,
"score": s.score,
"suspicion_level": s.suspicion_level,
"scored": s.scored,
"note": s.note,
"preview": s.preview,
}
for s in self.segments
],
"top_contributions": [
{"feature": name, "contribution": round(val, 4)}
for name, val in self.top_contributions
],
}
# ---------------------------------------------------------------------------
# 휴리스틱 baseline (모델 없을 때, 명시적 opt-in)
# ---------------------------------------------------------------------------
#: 미검증 참조 구간. 79권 인간 저작 코퍼스로 캘리브레이션하기 전까지는
#: 문헌상 경향을 반영한 자리표시자일 뿐이다. 절대 판정 근거가 아니다.
#: (feature, human_typical, ai_typical) — ai 쪽에 가까울수록 가점.
_HEURISTIC_RULES: tuple[tuple[str, float, float], ...] = (
("cv_sentence_len", 0.75, 0.35), # AI: 문장 길이 균일
("cv_eojeol_len", 0.62, 0.45), # AI: 어절 길이도 균일
("hapax_ratio", 0.78, 0.60), # AI: 어휘 재사용 잦음
("comma_per_sentence", 0.60, 1.40), # AI: 쉼표 과다 사용
("char3gram_repeat_ratio", 0.28, 0.42), # AI: 표현 반복
("word_bigram_repeat_ratio", 0.05, 0.14),
("mean_sentence_len", 38.0, 58.0), # AI: 만연체 경향
)
def _heuristic_score(feats: dict[str, float]) -> float:
"""규칙 기반 baseline 점수 (0~1). 결정적이며 근거를 추적할 수 있다.
각 규칙을 human_typical↔ai_typical 사이의 선형 위치로 환산해 평균한다.
학습 모델이 준비되면 즉시 대체될 임시 계층이다.
"""
positions: list[float] = []
for name, human_val, ai_val in _HEURISTIC_RULES:
val = feats.get(name)
if val is None:
continue
span = ai_val - human_val
if span == 0:
continue
pos = (val - human_val) / span
positions.append(max(0.0, min(1.0, pos)))
if not positions:
return 0.0
return max(0.0, min(1.0, sum(positions) / len(positions)))
def percentile(values: Sequence[float], pct: float) -> float:
"""선형 보간 백분위. numpy 없이도 동작하도록 직접 구현한다."""
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return float(ordered[0])
pos = (len(ordered) - 1) * max(0.0, min(100.0, pct)) / 100.0
low = int(math.floor(pos))
high = int(math.ceil(pos))
if low == high:
return float(ordered[low])
return float(ordered[low] + (ordered[high] - ordered[low]) * (pos - low))
def calibrate_cuts(
human_scores: Sequence[float],
low_percentile: float = DEFAULT_LOW_PERCENTILE,
high_percentile: float = DEFAULT_HIGH_PERCENTILE,
) -> tuple[float, float]:
"""인간 저작 코퍼스 점수 분포 → (low_cut, high_cut).
라벨이 전혀 필요 없다. "AI가 쓴 글의 점수는 얼마인가"가 아니라 "우리 코퍼스의
인간 저작물 중 상위 몇 %인가"로 컷을 정의하기 때문이다. 그래서 정확도를
측정하지 않고도 참인 진술("등록 자서전 대비 상위 2% 이례적 문체")이 되고,
'high' 배지 비율이 정의상 고정되어 검토 부하도 예측 가능해진다.
두 백분위가 같은 값으로 뭉개지면(분포가 평평한 경우) high 를 살짝 올려
medium 구간이 사라지지 않게 한다.
"""
if not human_scores:
raise ValueError("점수 표본이 비어 있어 컷을 산출할 수 없습니다.")
if not 0.0 <= low_percentile < high_percentile <= 100.0:
raise ValueError(
f"백분위는 0 <= low < high <= 100 이어야 합니다: {low_percentile}, {high_percentile}"
)
low = percentile(human_scores, low_percentile)
high = percentile(human_scores, high_percentile)
if high <= low:
high = min(1.0, low + 0.01)
return round(low, 4), round(high, 4)
def score_text_heuristic(text: str, use_pos: bool = True) -> float | None:
"""캘리브레이션용 단일 텍스트 점수. 너무 짧으면 None(표본에서 제외)."""
norm = normalize_text(text)
if len(norm) < HARD_MIN_CHARS:
return None
return _heuristic_score(extract_features(norm, use_pos=use_pos))
def _heuristic_contributions(feats: dict[str, float]) -> list[tuple[str, float]]:
"""휴리스틱에서 어느 특징이 점수를 끌어올렸는지."""
out: list[tuple[str, float]] = []
for name, human_val, ai_val in _HEURISTIC_RULES:
val = feats.get(name)
if val is None:
continue
span = ai_val - human_val
if span == 0:
continue
pos = max(0.0, min(1.0, (val - human_val) / span))
out.append((name, pos - 0.5))
out.sort(key=lambda kv: abs(kv[1]), reverse=True)
return out[:6]
# ---------------------------------------------------------------------------
# 모델 아티팩트
# ---------------------------------------------------------------------------
@dataclass
class ModelArtifact:
"""학습 산출물 + 재현에 필요한 메타데이터."""
estimator: object
feature_names: tuple[str, ...]
feature_set_version: str
model_version: str
requires_pos: bool
low_cut: float
high_cut: float
metrics: dict
zeroed_features: tuple[str, ...] = ()
trained_at: str = ""
sklearn_version: str = ""
notes: str = ""
def load_artifact(path: str | Path) -> ModelArtifact | None:
"""joblib 아티팩트 로드. 실패는 예외가 아니라 None (서비스는 계속 떠야 한다)."""
p = Path(path)
if not p.exists():
logger.info("AI detector model not found at %s — running unavailable", p)
return None
try:
import joblib
except Exception as exc:
logger.warning("joblib unavailable — cannot load AI detector model: %s", exc)
return None
try:
payload = joblib.load(p)
except Exception as exc:
logger.error("Failed to load AI detector artifact %s: %s", p, exc)
return None
if not isinstance(payload, dict) or "estimator" not in payload:
logger.error("Malformed AI detector artifact at %s (missing 'estimator')", p)
return None
names = tuple(payload.get("feature_names") or ())
if names and names != FEATURE_NAMES:
logger.error(
"AI detector artifact feature mismatch (artifact=%d, code=%d). "
"Retrain required — refusing to load.",
len(names),
len(FEATURE_NAMES),
)
return None
return ModelArtifact(
estimator=payload["estimator"],
feature_names=names or FEATURE_NAMES,
feature_set_version=payload.get("feature_set_version", "unknown"),
model_version=payload.get("model_version", "unknown"),
requires_pos=bool(payload.get("requires_pos", False)),
low_cut=float(payload.get("low_cut", DEFAULT_LOW_CUT)),
high_cut=float(payload.get("high_cut", DEFAULT_HIGH_CUT)),
metrics=payload.get("metrics", {}),
zeroed_features=tuple(payload.get("zeroed_features") or ()),
trained_at=payload.get("trained_at", ""),
sklearn_version=payload.get("sklearn_version", ""),
notes=payload.get("notes", ""),
)
# ---------------------------------------------------------------------------
# 탐지기
# ---------------------------------------------------------------------------
class AiGenerationDetector:
"""언어특징 기반 AI 생성 의심도 산출기.
동작 모드 (결과의 is_stub/model_version/note 로 항상 구분 가능):
· trained — 학습 아티팩트 로드 성공. is_stub=False.
· heuristic — 아티팩트 없음 + allow_heuristic=True. is_stub=True,
model_version="heuristic-baseline-v1".
· unavailable— 아티팩트 없음 + allow_heuristic=False(기본). available=False,
score=None.
"""
HEURISTIC_VERSION = "heuristic-baseline-v1"
def __init__(
self,
model_path: str | Path | None = None,
allow_heuristic: bool = False,
use_pos: bool = True,
low_cut: float | None = None,
high_cut: float | None = None,
):
self.model_path = str(
model_path or os.environ.get(ENV_MODEL_PATH) or DEFAULT_MODEL_PATH
)
self.allow_heuristic = allow_heuristic
self.use_pos = use_pos
# 휴리스틱 모드에서 쓸 백분위 컷. 둘 다 주어졌을 때만 적용한다.
self.low_cut = low_cut
self.high_cut = high_cut
self.artifact = load_artifact(self.model_path)
# -- 모드 --------------------------------------------------------------
@property
def mode(self) -> Literal["trained", "heuristic", "unavailable"]:
if self.artifact is not None:
return "trained"
return "heuristic" if self.allow_heuristic else "unavailable"
@property
def model_version(self) -> str:
if self.artifact is not None:
return self.artifact.model_version
if not self.allow_heuristic:
return "unavailable"
# 컷이 코퍼스로 캘리브레이션되었는지를 버전 문자열에 드러낸다.
return (
f"{self.HEURISTIC_VERSION}+corpus-percentile"
if self.cuts_calibrated else self.HEURISTIC_VERSION
)
@property
def cuts_calibrated(self) -> bool:
"""컷이 자리표시자가 아니라 실제 코퍼스 분포에서 나왔는지."""
if self.artifact is not None:
return True
return self.low_cut is not None and self.high_cut is not None
def _cuts(self) -> tuple[float, float]:
if self.artifact is not None:
return self.artifact.low_cut, self.artifact.high_cut
if self.low_cut is not None and self.high_cut is not None:
return self.low_cut, self.high_cut
return DEFAULT_LOW_CUT, DEFAULT_HIGH_CUT
def _level(self, score: float) -> SuspicionLevel:
low, high = self._cuts()
if score < low:
return "low"
if score < high:
return "medium"
return "high"
# -- 점수 --------------------------------------------------------------
def _score_features(self, feats: dict[str, float]) -> float | None:
"""특징 → 0~1 점수. 모드에 따라 학습 모델 또는 휴리스틱."""
if self.artifact is not None:
vec = features_to_vector(feats, self.artifact.zeroed_features)
try:
proba = self.artifact.estimator.predict_proba([vec])
return float(proba[0][1])
except Exception as exc:
logger.error("AI detector inference failed: %s", exc)
return None
if self.allow_heuristic:
return _heuristic_score(feats)
return None
def _contributions(self, feats: dict[str, float]) -> list[tuple[str, float]]:
"""설명용 상위 기여 특징.
선형 모델이면 coef × 표준화값을, 그 외에는 휴리스틱 규칙 위치를 쓴다.
기여도를 못 뽑는 모델(HGB 등)이면 빈 리스트 — 지어내지 않는다.
"""
if self.artifact is None:
return _heuristic_contributions(feats) if self.allow_heuristic else []
est = self.artifact.estimator
try:
coefs, scaler = _linear_parts(est)
if coefs is None:
return []
vec = features_to_vector(feats, self.artifact.zeroed_features)
if scaler is not None:
mean = getattr(scaler, "mean_", None)
scale = getattr(scaler, "scale_", None)
if mean is not None and scale is not None:
vec = [
(v - float(m)) / (float(s) if s else 1.0)
for v, m, s in zip(vec, mean, scale)
]
pairs = [
(name, float(c) * float(v))
for name, c, v in zip(FEATURE_NAMES, coefs, vec)
]
pairs.sort(key=lambda kv: abs(kv[1]), reverse=True)
return pairs[:6]
except Exception as exc:
logger.debug("Contribution extraction skipped: %s", exc)
return []
# -- 공개 API ----------------------------------------------------------
def detect(self, text: str, with_segments: bool = True) -> AiDetectionResult:
"""문서 1건의 AI 생성 의심도.
짧은 텍스트는 통계가 불안정하므로 HARD_MIN_CHARS 미만이면 채점을
거부하고, MIN_RELIABLE_CHARS 미만이면 경고를 붙인다.
"""
norm = normalize_text(text)
char_count = len(norm)
warnings: list[str] = []
if self.mode == "unavailable":
return AiDetectionResult(
available=False,
score=None,
suspicion_level=None,
provenance="unknown",
is_stub=False,
model_version="unavailable",
note=(
"AI 생성 판별 모델이 학습되지 않아 점수를 산출하지 않습니다. "
"scripts/train_ai_detector.py 로 학습 후 "
f"{ENV_MODEL_PATH} 를 지정하세요."
),
char_count=char_count,
warnings=["model_not_trained"],
)
if char_count < HARD_MIN_CHARS:
return AiDetectionResult(
available=False,
score=None,
suspicion_level=None,
provenance="unknown",
is_stub=self.artifact is None,
model_version=self.model_version,
note=(
f"텍스트가 너무 짧아({char_count}자) 언어특징 통계가 "
f"무의미합니다. 최소 {HARD_MIN_CHARS}자 필요."
),
char_count=char_count,
warnings=["text_too_short"],
)
if char_count < MIN_RELIABLE_CHARS:
warnings.append("short_text_low_confidence")
feats = extract_features(norm, use_pos=self.use_pos)
pos_ok = feats.get("pos_available", 0.0) >= 1.0
if not pos_ok:
warnings.append("pos_features_unavailable")
if self.artifact is not None and self.artifact.requires_pos and not pos_ok:
warnings.append("pos_required_by_model_but_missing")
logger.warning(
"Model %s was trained with POS features but kiwipiepy is "
"unavailable — score reliability degraded.",
self.artifact.model_version,
)
score = self._score_features(feats)
if score is None:
return AiDetectionResult(
available=False,
score=None,
suspicion_level=None,
provenance="unknown",
is_stub=self.artifact is None,
model_version=self.model_version,
note="점수 산출에 실패했습니다(추론 오류). 로그를 확인하세요.",
pos_available=pos_ok,
char_count=char_count,
warnings=warnings + ["inference_failed"],
features=feats,
)
segments = self._score_segments(norm) if with_segments else []
provenance = self._infer_provenance(score, segments)
if self.artifact is None and self.cuts_calibrated:
note = (
"학습 모델이 아니라 언어특징 규칙 점수입니다. 'AI일 확률'이 아니라 "
"등록 코퍼스의 인간 저작물 대비 문체 이례도이며, 검토 우선순위 "
"정렬용입니다. 판정 근거로 사용할 수 없습니다."
)
elif self.artifact is None:
note = (
"⚠️ 미검증 휴리스틱 baseline 입니다. 학습된 모델도 아니고 컷도 "
"캘리브레이션되지 않았습니다(자리표시자). 판정 근거로 사용할 수 "
"없습니다. scripts/calibrate_ai_detector_cuts.py 로 컷을 산출하세요."
)
else:
note = (
"AI 생성 '의심도'이며 확정 판정이 아닙니다. 대필·윤문된 원고는 "
"높게 나올 수 있으므로 반드시 사람 검토를 거치세요."
)
return AiDetectionResult(
available=True,
score=round(score, 4),
suspicion_level=self._level(score),
provenance=provenance,
is_stub=self.artifact is None,
model_version=self.model_version,
note=note,
pos_available=pos_ok,
char_count=char_count,
warnings=warnings,
segments=segments,
features=feats,
top_contributions=self._contributions(feats),
)
# -- 구간 채점 ---------------------------------------------------------
def _build_segments(self, norm: str) -> list[tuple[int, int, str]]:
"""문단을 SEGMENT_TARGET_CHARS 이상으로 병합. (start, end, text) 반환."""
segments: list[tuple[int, int, str]] = []
cursor = 0
buf_start: int | None = None
buf: list[str] = []
buf_len = 0
for para in split_paragraphs(norm):
idx = norm.find(para, cursor)
if idx < 0:
idx = cursor
cursor = idx + len(para)
if buf_start is None:
buf_start = idx
buf.append(para)
buf_len += len(para)
if buf_len >= SEGMENT_TARGET_CHARS:
segments.append((buf_start, cursor, "\n\n".join(buf)))
buf, buf_len, buf_start = [], 0, None
if buf and buf_start is not None:
if segments and buf_len < HARD_MIN_CHARS:
# 마지막 자투리는 직전 구간에 흡수 (단독 채점 불가 길이)
s, _, prev = segments[-1]
segments[-1] = (s, cursor, prev + "\n\n" + "\n\n".join(buf))
else:
segments.append((buf_start, cursor, "\n\n".join(buf)))
return segments
def _score_segments(self, norm: str) -> list[SegmentScore]:
out: list[SegmentScore] = []
for i, (start, end, seg_text) in enumerate(self._build_segments(norm)):
n = len(seg_text)
preview = seg_text[:60].replace("\n", " ")
if n < HARD_MIN_CHARS:
out.append(
SegmentScore(
index=i, start=start, end=end, char_count=n,
score=None, suspicion_level=None, scored=False,
note=f"{HARD_MIN_CHARS}자 미만 — 채점 제외",
preview=preview,
)
)
continue
feats = extract_features(seg_text, use_pos=self.use_pos)
s = self._score_features(feats)
if s is None:
out.append(
SegmentScore(
index=i, start=start, end=end, char_count=n,
score=None, suspicion_level=None, scored=False,
note="추론 실패", preview=preview,
)
)
continue
out.append(
SegmentScore(
index=i, start=start, end=end, char_count=n,
score=round(s, 4), suspicion_level=self._level(s),
scored=True,
note="" if n >= MIN_RELIABLE_CHARS else "짧은 구간 — 신뢰도 낮음",
preview=preview,
)
)
return out
# -- provenance --------------------------------------------------------
def _infer_provenance(
self, doc_score: float, segments: list[SegmentScore]
) -> Provenance:
"""작성 경로 **추정**. 확정이 아니며 검토자 참고용이다.
규칙 (docs/AI_DETECTION.md 와 동일하게 유지할 것):
human — 문서·구간 모두 low
ai — 문서 high 이고 low 구간이 없음
mixed — high 구간과 low 구간이 공존 (부분 삽입 의심)
edited — 전 구간이 medium 에 몰림 (AI 초안 + 사람 윤문, 또는 그 역)
unknown— 채점된 구간이 없어 판단 불가
"""
low, high = self._cuts()
scored = [s for s in segments if s.scored and s.score is not None]
if not scored:
if doc_score >= high:
return "ai"
if doc_score < low:
return "human"
return "edited"
levels = [s.suspicion_level for s in scored]
has_high = "high" in levels
has_low = "low" in levels
if has_high and has_low:
return "mixed"
if doc_score >= high and not has_low:
return "ai"
if doc_score < low and not has_high:
return "human"
if all(lv == "medium" for lv in levels):
return "edited"
return "mixed" if has_high else "edited"
# ---------------------------------------------------------------------------
# 싱글턴 접근자
# ---------------------------------------------------------------------------
_detector_cache: dict[tuple, AiGenerationDetector] = {}
def get_ai_detector(
model_path: str | Path | None = None,
allow_heuristic: bool | None = None,
use_pos: bool = True,
low_cut: float | None = None,
high_cut: float | None = None,
) -> AiGenerationDetector:
"""탐지기 인스턴스(프로세스 캐시).
allow_heuristic 을 생략하면 환경변수 AI_DETECTOR_ALLOW_HEURISTIC 을 따르고,
그것도 없으면 False(= 모델 없으면 unavailable) 다. 기본값을 False 로 두는
이유는, 미검증 점수가 조용히 운영에 노출되는 상황을 막기 위해서다.
low_cut/high_cut 은 휴리스틱 모드에서만 쓰이며, 둘 다 주어져야 적용된다.
"""
if allow_heuristic is None:
allow_heuristic = os.environ.get(
"AI_DETECTOR_ALLOW_HEURISTIC", ""
).strip().lower() in {"1", "true", "yes"}
key = (str(model_path or ""), bool(allow_heuristic), bool(use_pos), low_cut, high_cut)
if key not in _detector_cache:
_detector_cache[key] = AiGenerationDetector(
model_path=model_path, allow_heuristic=allow_heuristic, use_pos=use_pos,
low_cut=low_cut, high_cut=high_cut,
)
return _detector_cache[key]
def reset_detector_cache() -> None:
"""테스트/재로딩용."""
_detector_cache.clear()
def _linear_parts(estimator) -> tuple[Sequence[float] | None, object | None]:
"""추정기에서 (선형 계수, 스케일러) 추출. 없으면 (None, None).
Pipeline / CalibratedClassifierCV 를 한 겹씩 벗겨 본다.
"""
scaler = None
est = estimator
steps = getattr(est, "steps", None)
if steps:
for _, step in steps:
if hasattr(step, "mean_") and hasattr(step, "scale_"):
scaler = step
est = steps[-1][1]
calibrated = getattr(est, "calibrated_classifiers_", None)
if calibrated:
inner = getattr(calibrated[0], "estimator", None)
if inner is not None:
inner_steps = getattr(inner, "steps", None)
if inner_steps:
for _, step in inner_steps:
if hasattr(step, "mean_") and hasattr(step, "scale_"):
scaler = step
inner = inner_steps[-1][1]
est = inner
coef = getattr(est, "coef_", None)
if coef is None:
return None, scaler
return list(coef[0]), scaler