o2o-plagiarism-ai/tests/test_ai_detector.py

514 lines
20 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)