보유 코퍼스 31,560건이 전부 human 라벨이라 이진 판별기를 학습할 수 없고 (train_ai_detector.py 가 exit 2 로 막는다), 조사 결과 공개된 한국어 AI 생성 판별 데이터셋도 확인되지 않았다. AI 쪽 절반을 직접 만든다. build_ai_training_dataset.py 가 외부 호출을 금지하므로 생성은 별도 스크립트로 분리했다. 프롬프트는 이미 파생된 메타데이터만 쓰고 에피소드 본문은 어떤 경로로도 API 요청에 실리지 않는다. 주석만으로는 다음 사람이 META_COLUMNS 에 본문 컬럼을 한 줄 추가하는 것을 못 막으므로, assert_no_body_columns() 가 실행 시점에 차단하고 _build_prompt() 는 본문을 넘길 파라미터 자체가 없다. - 길이 매칭: human 분포에서 목표 길이를 뽑아 지시하고 벗어난 결과는 버린다. 분량이 다르면 판별기가 문체가 아니라 길이를 배운다. - 생성기 다중화: --model 반복 지정 시 라운드로빈. 1개면 그 모델만 잡는 판별기가 되므로 경고한다. - 문체 6종을 결정적으로 배분해 한 모델의 한 문체 암기를 막는다. - 생성문에도 human 과 같은 normalize_ocr 을 적용한다. 한쪽만 정규화하면 오염 방향만 뒤집힐 뿐이다. - append 전용 + prompt_id 스킵으로 재개 가능. dry-run 은 파일시스템도 건드리지 않는다. --base-url 로 사내 GPU 의 OpenAI 호환 서버를 쓰면 메타데이터조차 외부로 나가지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
102 lines
3.8 KiB
Python
102 lines
3.8 KiB
Python
"""generate_ai_samples 단위테스트. API 를 호출하지 않는다."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"generate_ai_samples", ROOT / "scripts" / "generate_ai_samples.py"
|
|
)
|
|
gen = importlib.util.module_from_spec(_spec)
|
|
sys.modules["generate_ai_samples"] = gen
|
|
_spec.loader.exec_module(gen)
|
|
|
|
|
|
class TestManuscriptNeverLeaves:
|
|
"""이 프로젝트에서 가장 깨지면 안 되는 성질 — 원고는 외부로 나가지 않는다."""
|
|
|
|
def test_meta_columns_contain_no_body_column(self):
|
|
for column in gen.META_COLUMNS:
|
|
assert not any(bad in column.lower() for bad in gen.TEXT_COLUMN_BLOCKLIST)
|
|
|
|
def test_guard_rejects_body_column(self):
|
|
"""META_COLUMNS 에 본문 컬럼을 넣는 수정은 실행 시점에 막혀야 한다."""
|
|
with pytest.raises(SystemExit) as err:
|
|
gen.assert_no_body_columns(["gpt_keyword", "에피소드"])
|
|
assert "에피소드" in str(err.value)
|
|
|
|
def test_guard_passes_clean_columns(self):
|
|
cols = ["gpt_keyword", "gpt_title"]
|
|
assert gen.assert_no_body_columns(cols) == cols
|
|
|
|
def test_prompt_contains_only_given_meta(self):
|
|
"""프롬프트에 넣지 않은 문자열은 절대 나타나지 않는다."""
|
|
secret = "이것은 저자의 실제 원고 본문이다"
|
|
prompt = gen._build_prompt(
|
|
{"gpt_keyword": "습관", "gpt_title": "변화"}, 950, "담담하게"
|
|
)
|
|
assert secret not in prompt
|
|
assert "습관" in prompt and "변화" in prompt
|
|
|
|
def test_build_prompt_signature_has_no_text_param(self):
|
|
"""본문을 넘길 자리 자체가 없어야 한다."""
|
|
import inspect
|
|
|
|
params = set(inspect.signature(gen._build_prompt).parameters)
|
|
assert params == {"meta", "target_chars", "style"}
|
|
|
|
|
|
class TestLengthBand:
|
|
def test_band_from_distribution(self, tmp_path):
|
|
import pandas as pd
|
|
|
|
frame = pd.DataFrame({"에피소드": ["가" * n for n in range(200, 1200, 10)]})
|
|
lengths, low, high = gen.load_human_length_band(frame, "에피소드")
|
|
assert low < high
|
|
assert min(lengths) >= 120 # 120자 미만은 채점 불가라 제외된다
|
|
assert low >= min(lengths) and high <= max(lengths)
|
|
|
|
def test_rejects_empty_column(self):
|
|
import pandas as pd
|
|
|
|
frame = pd.DataFrame({"에피소드": ["짧음"] * 5})
|
|
with pytest.raises(SystemExit):
|
|
gen.load_human_length_band(frame, "에피소드")
|
|
|
|
|
|
class TestResume:
|
|
def test_reads_done_ids(self, tmp_path):
|
|
out = tmp_path / "s.jsonl"
|
|
out.write_text(
|
|
json.dumps({"prompt_id": "aaa", "text": "x"}, ensure_ascii=False) + "\n"
|
|
+ json.dumps({"prompt_id": "bbb", "text": "y"}, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert gen.load_done_ids(out) == {"aaa", "bbb"}
|
|
|
|
def test_missing_file_is_empty(self, tmp_path):
|
|
assert gen.load_done_ids(tmp_path / "nope.jsonl") == set()
|
|
|
|
def test_broken_line_does_not_crash(self, tmp_path):
|
|
"""중간에 죽어 잘린 행이 있어도 재개는 되어야 한다."""
|
|
out = tmp_path / "s.jsonl"
|
|
out.write_text('{"prompt_id": "aaa"}\n{"prompt_id": "bro\n', encoding="utf-8")
|
|
assert gen.load_done_ids(out) == {"aaa"}
|
|
|
|
|
|
class TestDiversity:
|
|
def test_style_variants_are_distinct(self):
|
|
assert len(set(gen.STYLE_VARIANTS)) == len(gen.STYLE_VARIANTS)
|
|
assert len(gen.STYLE_VARIANTS) >= 4
|
|
|
|
def test_style_assignment_is_deterministic(self):
|
|
a = gen._stable_int("abc") % len(gen.STYLE_VARIANTS)
|
|
b = gen._stable_int("abc") % len(gen.STYLE_VARIANTS)
|
|
assert a == b
|