o2o-plagiarism-ai/tests/test_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

658 lines
26 KiB
Python

"""AI 생성 의심도 모듈 단위테스트.
원칙: 외부 모델 다운로드·네트워크·sklearn 설치 없이 전부 통과해야 한다.
학습 모델이 필요한 경로는 stub estimator 로 대체해 검증한다.
"""
from __future__ import annotations
import json
import pytest
from app.engine import ai_detector as ad
from app.engine.ai_detector import (
FEATURE_NAMES,
HARD_MIN_CHARS,
AiGenerationDetector,
ModelArtifact,
extract_features,
features_to_vector,
load_artifact,
normalize_text,
split_paragraphs,
split_sentences,
)
# ---------------------------------------------------------------------------
# 샘플 텍스트
# ---------------------------------------------------------------------------
# 문장 길이가 균일하고 쉼표가 많은 글 (AI 경향 쪽)
UNIFORM_TEXT = (
"그날의 기억은 오래도록 남아, 지금까지도 선명하게 떠오르는 장면이 되었다. "
"아침의 공기는 서늘했고, 골목을 지나는 사람들의 발걸음은 조용히 이어졌다. "
"어머니는 부엌에서 국을 끓였고, 그 냄새는 온 집안을 천천히 채워 나갔다. "
"나는 책상에 앉아 공책을 펼쳤고, 연필을 쥔 손에는 힘이 들어가 있었다. "
"창밖으로는 햇빛이 들어왔고, 마당의 나무는 잎을 조금씩 흔들고 있었다. "
"그 시절의 하루는 언제나, 비슷한 순서로 조용하게 흘러가고 있었다."
)
# 문장 길이가 들쭉날쭉하고 쉼표가 적은 글 (사람 경향 쪽)
BURSTY_TEXT = (
"비가 왔다. "
"나는 그날 아침에 학교에 가지 않았고 대신 뒷산에 올라가서 온종일 아무것도 하지 않은 채로 "
"그냥 젖은 흙냄새를 맡으며 앉아 있었는데 지금 생각하면 그게 무슨 의미였는지 잘 모르겠다. "
"춥지는 않았다. "
"형이 나를 찾으러 왔다. "
"우리는 말없이 내려왔고 집에 도착했을 때 어머니는 아무 말도 하지 않으셨다. "
"저녁을 먹었다. "
"그게 전부였다."
)
LONG_TEXT = "\n\n".join([UNIFORM_TEXT, BURSTY_TEXT, UNIFORM_TEXT, BURSTY_TEXT])
# ---------------------------------------------------------------------------
# 정규화 / 분해
# ---------------------------------------------------------------------------
def test_normalize_collapses_whitespace():
assert normalize_text("가나 다\r\n") == "가나 다\n"
assert normalize_text("") == ""
assert normalize_text(" \n ") == ""
def test_split_sentences_and_paragraphs():
assert len(split_sentences("첫 문장이다. 둘째 문장이다! 셋째인가?")) == 3
assert len(split_paragraphs("문단 하나.\n\n문단 둘.")) == 2
# 빈 입력에도 죽지 않아야 한다
assert split_sentences("") == []
assert split_paragraphs("") == []
# ---------------------------------------------------------------------------
# 특징 추출
# ---------------------------------------------------------------------------
def test_features_cover_all_names_and_are_finite():
feats = extract_features(UNIFORM_TEXT)
assert set(feats) == set(FEATURE_NAMES), "특징 키 누락/초과"
for name, val in feats.items():
assert isinstance(val, float), f"{name} 이 float 이 아님"
assert val == val, f"{name} 이 NaN"
def test_feature_vector_length_and_order_stable():
feats = extract_features(UNIFORM_TEXT)
vec = features_to_vector(feats)
assert len(vec) == len(FEATURE_NAMES)
# 순서 계약: 벡터 i번째는 FEATURE_NAMES[i] 값
for i, name in enumerate(FEATURE_NAMES):
assert vec[i] == pytest.approx(feats[name])
def test_zeroed_features_are_masked_but_length_preserved():
"""길이 특징 제외 시에도 벡터 길이는 불변이어야 한다 (모델 입력 계약)."""
feats = extract_features(UNIFORM_TEXT)
full = features_to_vector(feats)
masked = features_to_vector(feats, ad.LENGTH_FEATURES)
assert len(masked) == len(full) == len(FEATURE_NAMES)
for i, name in enumerate(FEATURE_NAMES):
if name in ad.LENGTH_FEATURES:
assert masked[i] == 0.0, f"{name} 이 0으로 눌리지 않음"
else:
assert masked[i] == pytest.approx(full[i])
assert full != masked, "길이 특징이 원래 0이면 이 테스트가 무의미해진다"
def test_detector_applies_artifact_zeroed_features():
"""학습 때 제외한 특징이 추론에서도 동일하게 제외되어야 한다."""
det = _stub_detector()
# 모든 특징에 균등 계수를 줘야 '제외되지 않으면 상위에 뜬다'가 성립한다
det.artifact.estimator.coef_ = [[1.0] * len(FEATURE_NAMES)]
det.artifact.zeroed_features = ad.LENGTH_FEATURES
res = det.detect(UNIFORM_TEXT, with_segments=False)
names = {n for n, _ in res.top_contributions}
assert names, "기여도가 비어 있으면 검증이 성립하지 않는다"
assert not (names & set(ad.LENGTH_FEATURES)), (
"제외된 길이 특징이 기여도에 나타나면 학습/추론 불일치"
)
def test_extract_features_is_deterministic():
a = extract_features(UNIFORM_TEXT)
b = extract_features(UNIFORM_TEXT)
assert a == b
def test_empty_text_yields_zero_vector():
feats = extract_features("")
assert all(v == 0.0 for v in feats.values())
assert len(features_to_vector(feats)) == len(FEATURE_NAMES)
def test_uniform_text_has_lower_sentence_variance_than_bursty():
"""문장 길이 변동계수는 AI 탐지의 핵심 표지 — 방향이 맞는지 확인."""
uniform = extract_features(UNIFORM_TEXT)
bursty = extract_features(BURSTY_TEXT)
assert uniform["cv_sentence_len"] < bursty["cv_sentence_len"]
assert uniform["comma_per_sentence"] > bursty["comma_per_sentence"]
def test_features_depend_on_structure_not_characters():
"""해시 더미가 아님을 보증 — 구조가 같으면 구조 특징도 같아야 한다."""
base = "가가가 가가가 가가가다. 가가가 가가가 가가가다. 가가가 가가가 가가가다."
swapped = base.replace("", "")
fa, fb = extract_features(base), extract_features(swapped)
for key in ("cv_sentence_len", "mean_sentence_len", "mean_eojeol_len",
"space_ratio", "sentence_count", "eojeol_count"):
assert fa[key] == pytest.approx(fb[key]), key
def test_pos_features_absent_when_kiwi_unavailable(monkeypatch):
monkeypatch.setattr(ad, "_get_kiwi", lambda: None)
feats = extract_features(UNIFORM_TEXT)
assert feats["pos_available"] == 0.0
assert feats["morph_count"] == 0.0
# 벡터 길이는 그대로여야 한다 (모델 입력 계약)
assert len(features_to_vector(feats)) == len(FEATURE_NAMES)
def test_pos_extraction_survives_tokenizer_exception(monkeypatch):
class Boom:
def tokenize(self, text):
raise RuntimeError("tokenizer exploded")
monkeypatch.setattr(ad, "_get_kiwi", lambda: Boom())
feats = extract_features(UNIFORM_TEXT) # 예외가 새어 나오면 실패
assert feats["pos_available"] == 0.0
assert feats["char_count"] > 0
# ---------------------------------------------------------------------------
# 미가용 모드 — 점수를 지어내지 않는다
# ---------------------------------------------------------------------------
def test_unavailable_when_no_model(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=False)
assert det.mode == "unavailable"
res = det.detect(UNIFORM_TEXT)
assert res.available is False
assert res.score is None
assert res.suspicion_level is None
assert res.provenance == "unknown"
assert res.model_version == "unavailable"
assert "model_not_trained" in res.warnings
assert res.is_stub is False # 더미를 준 게 아니라 아예 안 준 것
def test_load_artifact_missing_file_returns_none(tmp_path):
assert load_artifact(tmp_path / "absent.joblib") is None
# ---------------------------------------------------------------------------
# 휴리스틱 baseline 모드 — 반드시 스텁임이 드러나야 한다
# ---------------------------------------------------------------------------
def test_heuristic_mode_is_flagged_as_stub(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
assert det.mode == "heuristic"
res = det.detect(UNIFORM_TEXT)
assert res.available is True
assert res.is_stub is True
assert res.model_version == "heuristic-baseline-v1"
assert "휴리스틱" in res.note
assert 0.0 <= res.score <= 1.0
assert res.suspicion_level in ("low", "medium", "high")
def test_heuristic_is_deterministic(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
first = det.detect(UNIFORM_TEXT)
second = det.detect(UNIFORM_TEXT)
assert first.score == second.score
assert first.provenance == second.provenance
def test_heuristic_ranks_uniform_above_bursty(tmp_path):
"""규칙이 실제로 언어특징을 반영하는지 (방향성 검증)."""
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
uniform = det.detect(UNIFORM_TEXT, with_segments=False)
bursty = det.detect(BURSTY_TEXT, with_segments=False)
assert uniform.score > bursty.score
def test_heuristic_score_is_pure_function_of_features():
low = dict.fromkeys(FEATURE_NAMES, 0.0)
low.update({"cv_sentence_len": 0.75, "comma_per_sentence": 0.60,
"hapax_ratio": 0.78, "mean_sentence_len": 38.0})
high = dict.fromkeys(FEATURE_NAMES, 0.0)
high.update({"cv_sentence_len": 0.35, "comma_per_sentence": 1.40,
"hapax_ratio": 0.60, "mean_sentence_len": 58.0})
assert ad._heuristic_score(high) > ad._heuristic_score(low)
assert ad._heuristic_score(low) == ad._heuristic_score(dict(low))
def test_heuristic_contributions_are_reported(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
res = det.detect(UNIFORM_TEXT, with_segments=False)
assert res.top_contributions, "설명 근거가 비어 있으면 안 된다"
names = {n for n, _ in res.top_contributions}
assert names <= set(FEATURE_NAMES)
# ---------------------------------------------------------------------------
# 짧은 텍스트 처리
# ---------------------------------------------------------------------------
def test_too_short_text_refuses_to_score(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
res = det.detect("짧은 글이다. 이건 통계가 안 나온다.")
assert res.available is False
assert res.score is None
assert "text_too_short" in res.warnings
assert str(HARD_MIN_CHARS) in res.note
def test_short_but_scorable_text_gets_warning(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
text = "오늘은 비가 내렸다. " * 12 # HARD_MIN 초과, MIN_RELIABLE 미만 구간
assert HARD_MIN_CHARS <= len(text.strip()) < ad.MIN_RELIABLE_CHARS
res = det.detect(text)
assert res.available is True
assert "short_text_low_confidence" in res.warnings
# ---------------------------------------------------------------------------
# 구간(segment) 채점
# ---------------------------------------------------------------------------
def test_segments_have_valid_offsets(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
res = det.detect(LONG_TEXT)
assert res.segments, "긴 글은 구간이 나와야 한다"
norm = normalize_text(LONG_TEXT)
for seg in res.segments:
assert 0 <= seg.start <= seg.end <= len(norm)
assert seg.char_count > 0
if seg.scored:
assert seg.score is not None and 0.0 <= seg.score <= 1.0
assert seg.suspicion_level in ("low", "medium", "high")
else:
assert seg.score is None
def test_segments_can_be_disabled(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
res = det.detect(LONG_TEXT, with_segments=False)
assert res.segments == []
# ---------------------------------------------------------------------------
# provenance
# ---------------------------------------------------------------------------
def test_provenance_is_valid_value(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
for text in (UNIFORM_TEXT, BURSTY_TEXT, LONG_TEXT):
res = det.detect(text)
assert res.provenance in ("human", "ai", "mixed", "edited", "unknown")
def test_provenance_mixed_when_segments_span_both_ends(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
segs = [
ad.SegmentScore(index=0, start=0, end=10, char_count=600, score=0.9,
suspicion_level="high", scored=True),
ad.SegmentScore(index=1, start=10, end=20, char_count=600, score=0.1,
suspicion_level="low", scored=True),
]
assert det._infer_provenance(0.5, segs) == "mixed"
def test_provenance_human_when_all_low(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
segs = [
ad.SegmentScore(index=i, start=0, end=10, char_count=600, score=0.1,
suspicion_level="low", scored=True)
for i in range(3)
]
assert det._infer_provenance(0.1, segs) == "human"
def test_provenance_edited_when_all_medium(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=True)
segs = [
ad.SegmentScore(index=i, start=0, end=10, char_count=600, score=0.55,
suspicion_level="medium", scored=True)
for i in range(3)
]
assert det._infer_provenance(0.55, segs) == "edited"
# ---------------------------------------------------------------------------
# 학습 모델 경로 (stub estimator — sklearn/joblib 불필요)
# ---------------------------------------------------------------------------
class _StubEstimator:
"""coef_ 를 가진 최소 선형 분류기 흉내. 첫 특징만 보고 확률을 낸다."""
def __init__(self, probability: float = 0.82):
self.probability = probability
self.coef_ = [[0.0] * len(FEATURE_NAMES)]
self.coef_[0][FEATURE_NAMES.index("cv_sentence_len")] = -1.5
self.coef_[0][FEATURE_NAMES.index("comma_per_sentence")] = 1.2
def predict_proba(self, X):
return [[1.0 - self.probability, self.probability] for _ in X]
def _stub_detector(probability: float = 0.82, requires_pos: bool = False):
det = AiGenerationDetector(model_path="/nonexistent", allow_heuristic=False)
det.artifact = ModelArtifact(
estimator=_StubEstimator(probability),
feature_names=FEATURE_NAMES,
feature_set_version=ad.FEATURE_SET_VERSION,
model_version="stub-test-v1",
requires_pos=requires_pos,
low_cut=0.40,
high_cut=0.70,
metrics={},
)
return det
def test_trained_mode_reports_not_stub():
det = _stub_detector(0.82)
assert det.mode == "trained"
res = det.detect(UNIFORM_TEXT)
assert res.available is True
assert res.is_stub is False
assert res.model_version == "stub-test-v1"
assert res.score == pytest.approx(0.82)
assert res.suspicion_level == "high"
assert "확정 판정이 아닙니다" in res.note
def test_trained_mode_uses_artifact_cuts():
assert _stub_detector(0.10).detect(UNIFORM_TEXT).suspicion_level == "low"
assert _stub_detector(0.55).detect(UNIFORM_TEXT).suspicion_level == "medium"
assert _stub_detector(0.95).detect(UNIFORM_TEXT).suspicion_level == "high"
def test_linear_contributions_extracted_from_estimator():
det = _stub_detector()
res = det.detect(UNIFORM_TEXT, with_segments=False)
names = {n for n, _ in res.top_contributions}
# 계수가 0이 아닌 특징이 상위에 올라와야 한다
assert {"cv_sentence_len", "comma_per_sentence"} & names
def test_pos_mismatch_warning_when_model_requires_pos(monkeypatch):
monkeypatch.setattr(ad, "_get_kiwi", lambda: None)
det = _stub_detector(requires_pos=True)
res = det.detect(UNIFORM_TEXT, with_segments=False)
assert "pos_required_by_model_but_missing" in res.warnings
assert res.pos_available is False
def test_inference_failure_is_reported_not_swallowed():
class Exploding:
def predict_proba(self, X):
raise RuntimeError("boom")
det = _stub_detector()
det.artifact.estimator = Exploding()
res = det.detect(UNIFORM_TEXT)
assert res.available is False
assert res.score is None
assert "inference_failed" in res.warnings
# ---------------------------------------------------------------------------
# 직렬화
# ---------------------------------------------------------------------------
def test_to_dict_is_json_serializable():
det = _stub_detector()
payload = det.detect(LONG_TEXT).to_dict()
encoded = json.dumps(payload, ensure_ascii=False)
restored = json.loads(encoded)
assert restored["model_version"] == "stub-test-v1"
assert restored["is_stub"] is False
assert restored["provenance"] in ("human", "ai", "mixed", "edited", "unknown")
assert isinstance(restored["segments"], list)
def test_to_dict_of_unavailable_result(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "nope.joblib", allow_heuristic=False)
payload = det.detect(UNIFORM_TEXT).to_dict()
json.dumps(payload, ensure_ascii=False) # 예외 없으면 통과
assert payload["available"] is False
assert payload["score"] is None
# ---------------------------------------------------------------------------
# 싱글턴 접근자
# ---------------------------------------------------------------------------
def test_get_ai_detector_respects_env(monkeypatch, tmp_path):
ad.reset_detector_cache()
monkeypatch.setenv("AI_DETECTOR_ALLOW_HEURISTIC", "true")
monkeypatch.setenv(ad.ENV_MODEL_PATH, str(tmp_path / "none.joblib"))
det = ad.get_ai_detector()
assert det.allow_heuristic is True
assert det.mode == "heuristic"
ad.reset_detector_cache()
def test_get_ai_detector_defaults_to_unavailable(monkeypatch, tmp_path):
ad.reset_detector_cache()
monkeypatch.delenv("AI_DETECTOR_ALLOW_HEURISTIC", raising=False)
monkeypatch.setenv(ad.ENV_MODEL_PATH, str(tmp_path / "none.joblib"))
det = ad.get_ai_detector()
assert det.allow_heuristic is False
assert det.mode == "unavailable"
ad.reset_detector_cache()
# ---------------------------------------------------------------------------
# 아티팩트 파일 검증 (joblib 있을 때만)
# ---------------------------------------------------------------------------
try:
import joblib
except ImportError: # joblib 미설치 환경에서도 나머지 테스트는 돌아야 한다
joblib = None
requires_joblib = pytest.mark.skipif(
joblib is None, reason="joblib 미설치 — 파일 아티팩트 검증 생략"
)
@requires_joblib
def test_malformed_artifact_returns_none(tmp_path):
path = tmp_path / "bad.joblib"
joblib.dump({"not_an_estimator": 1}, path)
assert load_artifact(path) is None
@requires_joblib
def test_feature_mismatch_artifact_is_rejected(tmp_path):
path = tmp_path / "mismatch.joblib"
joblib.dump(
{"estimator": _StubEstimator(), "feature_names": ("only_one_feature",)},
path,
)
assert load_artifact(path) is None, "특징 불일치 아티팩트는 로드 거부되어야 한다"
@requires_joblib
def test_valid_artifact_roundtrip(tmp_path):
path = tmp_path / "ok.joblib"
joblib.dump(
{
"estimator": _StubEstimator(0.66),
"feature_names": tuple(FEATURE_NAMES),
"feature_set_version": ad.FEATURE_SET_VERSION,
"model_version": "roundtrip-v1",
"requires_pos": False,
"low_cut": 0.4,
"high_cut": 0.7,
"metrics": {"test": {"auroc": 0.9}},
},
path,
)
art = load_artifact(path)
assert art is not None
assert art.model_version == "roundtrip-v1"
det = AiGenerationDetector(model_path=path, allow_heuristic=False)
assert det.mode == "trained"
assert det.detect(UNIFORM_TEXT, with_segments=False).score == pytest.approx(0.66)
# ---------------------------------------------------------------------------
# 백분위 컷 캘리브레이션 (라벨 없이 인간 코퍼스만으로)
# ---------------------------------------------------------------------------
def test_percentile_matches_known_values():
values = [0.0, 0.25, 0.5, 0.75, 1.0]
assert ad.percentile(values, 0) == pytest.approx(0.0)
assert ad.percentile(values, 50) == pytest.approx(0.5)
assert ad.percentile(values, 100) == pytest.approx(1.0)
assert ad.percentile([], 50) == 0.0
assert ad.percentile([0.3], 90) == pytest.approx(0.3)
def test_calibrate_cuts_from_human_scores():
scores = [i / 1000 for i in range(1000)] # 0.000 ~ 0.999 균등
low, high = ad.calibrate_cuts(scores, 90, 98)
assert low == pytest.approx(0.899, abs=0.01)
assert high == pytest.approx(0.979, abs=0.01)
assert low < high
def test_calibrate_cuts_keeps_medium_band_when_distribution_is_flat():
low, high = ad.calibrate_cuts([0.5] * 200, 90, 98)
assert high > low, "분포가 평평해도 medium 구간이 사라지면 안 된다"
def test_calibrate_cuts_rejects_bad_input():
with pytest.raises(ValueError):
ad.calibrate_cuts([])
with pytest.raises(ValueError):
ad.calibrate_cuts([0.5], 98, 90) # low >= high
with pytest.raises(ValueError):
ad.calibrate_cuts([0.5], -1, 50)
def test_calibrated_cuts_produce_expected_badge_rate():
"""컷의 존재 이유 — 인간 코퍼스에서 high 비율이 설계값으로 고정된다."""
scores = [i / 1000 for i in range(1000)]
low, high = ad.calibrate_cuts(scores, 90, 98)
high_rate = sum(1 for s in scores if s >= high) / len(scores)
assert high_rate == pytest.approx(0.02, abs=0.005)
def test_score_text_heuristic_skips_short_text():
assert ad.score_text_heuristic("너무 짧다.") is None
value = ad.score_text_heuristic(UNIFORM_TEXT)
assert value is not None and 0.0 <= value <= 1.0
def test_detector_uses_injected_cuts(tmp_path):
det = AiGenerationDetector(
model_path=tmp_path / "none.joblib", allow_heuristic=True,
low_cut=0.10, high_cut=0.20,
)
assert det.cuts_calibrated is True
assert det._cuts() == (0.10, 0.20)
assert det.model_version == "heuristic-baseline-v1+corpus-percentile"
assert det._level(0.05) == "low"
assert det._level(0.15) == "medium"
assert det._level(0.50) == "high"
def test_partial_cuts_are_ignored(tmp_path):
"""한쪽만 주면 자리표시자로 되돌아가야 한다 (반쪽 설정 방지)."""
det = AiGenerationDetector(
model_path=tmp_path / "none.joblib", allow_heuristic=True, low_cut=0.1,
)
assert det.cuts_calibrated is False
assert det._cuts() == (ad.DEFAULT_LOW_CUT, ad.DEFAULT_HIGH_CUT)
assert det.model_version == "heuristic-baseline-v1"
def test_uncalibrated_heuristic_note_warns(tmp_path):
det = AiGenerationDetector(model_path=tmp_path / "none.joblib", allow_heuristic=True)
res = det.detect(UNIFORM_TEXT, with_segments=False)
assert res.is_stub is True
assert "캘리브레이션되지 않았습니다" in res.note
def test_calibrated_heuristic_note_states_relative_meaning(tmp_path):
det = AiGenerationDetector(
model_path=tmp_path / "none.joblib", allow_heuristic=True,
low_cut=0.3, high_cut=0.6,
)
res = det.detect(UNIFORM_TEXT, with_segments=False)
assert res.is_stub is True, "캘리브레이션해도 학습 모델은 아니다"
assert "문체 이례도" in res.note
assert "확률" in res.note
def test_artifact_cuts_win_over_injected_cuts():
det = _stub_detector(0.5)
det.low_cut, det.high_cut = 0.01, 0.02
assert det._cuts() == (0.40, 0.70), "학습 아티팩트가 있으면 그쪽이 우선"
def test_calibration_script_refuses_degenerate_corpus(tmp_path):
"""포화된 코퍼스에서 'high 배지 0건' 설정을 출력하면 안 된다."""
import subprocess
import sys
from pathlib import Path
ref = tmp_path / "ref"
ref.mkdir()
# 완전히 동일한 텍스트 200건 → 점수 전부 동률 = 포화
for i in range(200):
(ref / f"ref-{i:04d}__같은책.txt").write_text(UNIFORM_TEXT, encoding="utf-8")
root = Path(__file__).resolve().parents[1]
proc = subprocess.run(
[sys.executable, "scripts/calibrate_ai_detector_cuts.py",
"--database", str(tmp_path / "absent.sqlite3"),
"--reference-dir", str(ref), "--sample", "0",
"--out", str(tmp_path / "cuts.json")],
cwd=root, capture_output=True, text=True,
)
assert proc.returncode == 3, proc.stderr[-1500:]
assert "AI_DETECTOR_LOW_CUT" not in proc.stdout, "죽은 설정을 출력하면 안 된다"
assert "영원히 0건" in proc.stderr
# 진단 리포트는 남아야 한다
assert (tmp_path / "cuts.json").exists()
def test_calibration_script_fails_on_tiny_sample(tmp_path):
import subprocess
import sys
from pathlib import Path
ref = tmp_path / "ref"
ref.mkdir()
for i in range(5):
(ref / f"ref-{i}__책.txt").write_text(UNIFORM_TEXT, encoding="utf-8")
proc = subprocess.run(
[sys.executable, "scripts/calibrate_ai_detector_cuts.py",
"--database", str(tmp_path / "absent.sqlite3"),
"--reference-dir", str(ref), "--sample", "0",
"--out", str(tmp_path / "cuts.json")],
cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True,
)
assert proc.returncode == 2
assert "불안정" in proc.stderr