"""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)