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