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