422 lines
19 KiB
Python
422 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""AI 생성 표본 제작 — 판별기 학습셋의 AI 쪽 절반을 만든다.
|
|
|
|
왜 필요한가:
|
|
보유 코퍼스는 79권 31,560건이 전부 human 라벨이다. 이진 판별기는 한쪽
|
|
라벨만으로 학습할 수 없고(`train_ai_detector.py` 가 exit 2 로 막는다),
|
|
공개된 한국어 AI 생성 판별 데이터셋은 확인되지 않았다. 그래서 직접 만든다.
|
|
|
|
⚠️ 원고를 외부로 내보내지 않는다:
|
|
프롬프트는 **이미 파생된 메타데이터만** 쓴다(`gpt_keyword`, `gpt_title`,
|
|
`gpt_persona_keyword`, `gpt_character_keyword`). 에피소드 본문은 어떤
|
|
경로로도 API 요청에 실리지 않으며, `_build_prompt` 가 본문 컬럼을 아예
|
|
받지 않는 구조로 그것을 강제한다. 이 성질을 깨는 수정을 하지 말 것.
|
|
(`build_ai_training_dataset.py` 가 외부 호출을 금지하는 것과 같은 이유다.)
|
|
|
|
설계상 반드시 지켜야 하는 것:
|
|
1. **길이 매칭** — human 에피소드는 780~1,029자(p10~p90)에 몰려 있다. 분량이
|
|
다르면 판별기는 문체가 아니라 길이를 배운다(docs/AI_DETECTION.md 경고).
|
|
목표 길이를 human 분포에서 뽑아 지시하고, 벗어난 결과는 버린다.
|
|
2. **생성기 다중화** — 한 모델로만 만들면 그 모델만 잡는 판별기가 된다.
|
|
`--model` 을 반복 지정하면 라운드로빈으로 섞는다. 최소 2개를 권장한다.
|
|
3. **동일 정규화** — 생성문에도 human 과 똑같이 `normalize_ocr` 을 적용한다.
|
|
한쪽만 정규화하면 오염 방향만 뒤집힐 뿐이다.
|
|
4. **재개 가능** — append 전용 JSONL 이고, 재실행하면 이미 만든 prompt_id 를
|
|
건너뛴다. 중간에 죽어도 호출 비용을 다시 쓰지 않는다.
|
|
|
|
로컬 GPU 사용:
|
|
--base-url 로 OpenAI 호환 서버(vLLM 등)를 가리키면 사내 GPU 의 오픈소스
|
|
모델로 생성할 수 있다. 원고 메타데이터조차 외부로 내보내지 않는 경로다.
|
|
|
|
사용:
|
|
# 0) 비용 0 — 프롬프트만 확인
|
|
python scripts/generate_ai_samples.py --xlsx <파일> --limit 5 --dry-run
|
|
|
|
# 1) 실제 생성 (모델 2개 혼합)
|
|
python scripts/generate_ai_samples.py --xlsx <파일> \\
|
|
--model gpt-4o-mini --model gpt-4.1-mini \\
|
|
--limit 4000 --out data/training/ai_samples.jsonl
|
|
|
|
출력 JSONL 1행 (build_ai_training_dataset.py --ai-jsonl 이 그대로 읽는다):
|
|
{"text": "...", "generator": "gpt-4o-mini", "book": "", "prompt_id": "...",
|
|
"target_chars": 950, "char_count": 963, "style": "...", "meta": {...}}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
|
logger = logging.getLogger("gen-ai-samples")
|
|
|
|
from app.engine.ocr_normalize import normalize_ocr # noqa: E402
|
|
from app.engine.training_data import pseudonymous_id, sanitize_prompt_metadata # noqa: E402
|
|
|
|
#: 프롬프트에 쓸 수 있는 컬럼. 본문 컬럼은 의도적으로 목록에 없다.
|
|
META_COLUMNS = (
|
|
"gpt_keyword",
|
|
"gpt_title",
|
|
"gpt_persona_keyword",
|
|
"gpt_character_keyword",
|
|
"생애사건_category",
|
|
)
|
|
TEXT_COLUMN_BLOCKLIST = ("본문", "내용", "원고", "text", "content", "body")
|
|
TEXT_COLUMN_EXACT_BLOCKLIST = ("에피소드", "story")
|
|
|
|
SYSTEM_PROMPT = (
|
|
"당신은 한국어 자서전 원고를 쓰는 작가다. 주어진 소재로 자서전의 한 "
|
|
"에피소드를 쓴다. 제목·머리말·목록·마크다운을 쓰지 말고 본문 산문만 쓴다. "
|
|
"설명하거나 요약하지 말고, 장면과 감정을 가진 이야기로 쓴다."
|
|
)
|
|
|
|
#: 한 모델이 한 가지 문체로만 쓰면 판별기가 그 문체를 외운다. 지시를 흩는다.
|
|
STYLE_VARIANTS = (
|
|
"담담하고 절제된 문체로, 감정을 직접 말하지 말고 장면으로 보여준다.",
|
|
"회상하는 노년의 목소리로, 긴 문장과 짧은 문장을 섞어 쓴다.",
|
|
"대화를 두세 번 넣고, 인물의 말버릇이 드러나게 쓴다.",
|
|
"구체적인 시간·장소·사물 이름을 여러 개 넣어 사실적으로 쓴다.",
|
|
"한 가지 감각(냄새·소리·촉감)을 중심으로 장면을 풀어 쓴다.",
|
|
"후회와 자기변명이 뒤섞인 솔직한 1인칭으로 쓴다.",
|
|
)
|
|
|
|
|
|
def _stable_int(key: str) -> int:
|
|
return int(hashlib.sha1(key.encode("utf-8")).hexdigest()[:12], 16)
|
|
|
|
|
|
def assert_no_body_columns(columns: list[str]) -> list[str]:
|
|
"""본문 컬럼이 프롬프트 소재에 섞이는 것을 코드로 막는다.
|
|
|
|
주석만으로는 다음 사람이 META_COLUMNS 에 본문 컬럼을 한 줄 추가하는 것을
|
|
못 막는다. 그 한 줄이 원고를 외부 API 로 내보내는 경로가 된다.
|
|
"""
|
|
leaked = [
|
|
c for c in columns
|
|
if c.strip().lower() in TEXT_COLUMN_EXACT_BLOCKLIST
|
|
or any(bad in c.lower() for bad in TEXT_COLUMN_BLOCKLIST)
|
|
]
|
|
if leaked:
|
|
raise SystemExit(
|
|
f"본문으로 보이는 컬럼이 프롬프트 소재에 있습니다: {leaked}. "
|
|
"원고는 외부로 내보내지 않습니다. META_COLUMNS 를 확인하세요."
|
|
)
|
|
return columns
|
|
|
|
|
|
def _build_prompt(meta: dict[str, str], target_chars: int, style: str) -> str:
|
|
"""파생 메타데이터만으로 프롬프트를 만든다. 본문은 인자로 들어오지 않는다."""
|
|
lines = [f"{k}: {v}" for k, v in meta.items() if v]
|
|
soil = "\n".join(lines) if lines else "(소재 없음 — 평범한 유년의 한 장면)"
|
|
return (
|
|
f"아래 소재로 자서전 에피소드를 한국어로 쓴다.\n\n{soil}\n\n"
|
|
f"문체 지시: {style}\n"
|
|
f"분량: 공백 포함 {target_chars}자 내외(±15%). 이 분량을 반드시 지킨다.\n"
|
|
f"본문만 출력한다."
|
|
)
|
|
|
|
|
|
def _column_values(frame, column: str):
|
|
"""pandas DataFrame(테스트/하위호환) 또는 dict 행 목록에서 컬럼을 읽는다."""
|
|
if isinstance(frame, list):
|
|
return [row.get(column) for row in frame]
|
|
return frame[column].tolist()
|
|
|
|
|
|
def load_xlsx_rows(path: Path) -> tuple[list[str], list[dict[str, object]]]:
|
|
from openpyxl import load_workbook
|
|
|
|
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
worksheet = workbook.worksheets[0]
|
|
iterator = worksheet.iter_rows(values_only=True)
|
|
try:
|
|
headers = [str(v).strip() if v is not None else "" for v in next(iterator)]
|
|
except StopIteration:
|
|
workbook.close()
|
|
raise SystemExit(f"빈 xlsx입니다: {path}")
|
|
rows = [dict(zip(headers, row)) for row in iterator]
|
|
workbook.close()
|
|
return headers, rows
|
|
|
|
|
|
def load_human_length_band(frame, text_column: str) -> tuple[list[int], int, int]:
|
|
"""human 분포에서 목표 길이 후보와 허용 구간을 뽑는다."""
|
|
lengths = sorted(
|
|
len(str(value)) for value in _column_values(frame, text_column)
|
|
if value is not None and len(str(value)) >= 120
|
|
)
|
|
if not lengths:
|
|
raise SystemExit(f"'{text_column}' 에서 길이를 잴 수 있는 행이 없습니다.")
|
|
|
|
def pct(p: float) -> int:
|
|
return lengths[min(len(lengths) - 1, int(len(lengths) * p / 100))]
|
|
|
|
return lengths, pct(5), pct(95)
|
|
|
|
|
|
def load_done_ids(out_path: Path) -> set[str]:
|
|
"""이미 만든 prompt_id. 재실행 시 호출 비용을 다시 쓰지 않기 위한 것."""
|
|
done: set[str] = set()
|
|
if not out_path.exists():
|
|
return done
|
|
for line in out_path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
done.add(json.loads(line)["prompt_id"])
|
|
except (json.JSONDecodeError, KeyError):
|
|
logger.warning("기존 출력에 깨진 행이 있어 건너뜁니다.")
|
|
return done
|
|
|
|
|
|
def _hangul_ratio(text: str) -> float:
|
|
"""영문 오류·깨진 출력을 거르기 위한 경량 품질 게이트."""
|
|
letters = [ch for ch in text if ch.isalpha()]
|
|
if not letters:
|
|
return 0.0
|
|
return sum("가" <= ch <= "힣" for ch in letters) / len(letters)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
ap.add_argument("--xlsx", type=Path, required=True, help="소재를 뽑을 에피소드 xlsx")
|
|
ap.add_argument("--text-column", default="에피소드",
|
|
help="길이 분포 계산에만 쓴다. 본문은 API 로 보내지 않는다.")
|
|
ap.add_argument(
|
|
"--meta-column", action="append", default=[],
|
|
help="프롬프트 소재 컬럼(반복 지정). 제목·키워드만 허용하며 본문은 차단",
|
|
)
|
|
ap.add_argument(
|
|
"--group-column", default=None,
|
|
help="human과 같은 작성자 그룹으로 묶을 컬럼(원본값은 저장·전송하지 않음)",
|
|
)
|
|
ap.add_argument("--group-salt-env", default="DATA_ANONYMIZATION_SALT")
|
|
ap.add_argument("--model", action="append", default=[],
|
|
help="생성 모델 (반복 지정 = 혼합). 최소 2개 권장")
|
|
ap.add_argument("--base-url", default=None,
|
|
help="OpenAI 호환 엔드포인트 (사내 vLLM 등)")
|
|
ap.add_argument("--limit", type=int, default=100, help="생성할 표본 수")
|
|
ap.add_argument("--temperature", type=float, default=1.0)
|
|
ap.add_argument("--concurrency", type=int, default=1,
|
|
help="동시 생성 요청 수(로컬 서버 슬롯 수 이하)")
|
|
ap.add_argument("--max-tokens", type=int, default=2200,
|
|
help="요청당 최대 생성 토큰")
|
|
ap.add_argument("--min-hangul-ratio", type=float, default=0.55,
|
|
help="알파벳 문자 중 최소 한글 비율")
|
|
ap.add_argument("--seed", type=int, default=20260819)
|
|
ap.add_argument("--shard-count", type=int, default=1,
|
|
help="소재 행을 서로 겹치지 않는 N개 샤드로 분할")
|
|
ap.add_argument("--shard-index", type=int, default=0,
|
|
help="이 프로세스가 담당할 0-based 샤드")
|
|
ap.add_argument("--out", type=Path, default=Path("data/training/ai_samples.jsonl"))
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="프롬프트만 출력하고 API 를 호출하지 않는다 (비용 0)")
|
|
args = ap.parse_args()
|
|
if args.concurrency < 1:
|
|
ap.error("--concurrency는 1 이상이어야 합니다")
|
|
if args.shard_count < 1 or not 0 <= args.shard_index < args.shard_count:
|
|
ap.error("--shard-count는 1 이상, --shard-index는 0 이상 shard-count 미만이어야 합니다")
|
|
|
|
columns, rows = load_xlsx_rows(args.xlsx)
|
|
if args.text_column not in columns:
|
|
raise SystemExit(f"길이 분포 컬럼이 없습니다: {args.text_column}")
|
|
lengths, low_chars, high_chars = load_human_length_band(rows, args.text_column)
|
|
logger.info(
|
|
"human 길이 분포: 중앙값 %d자, 허용 구간 %d~%d자 (n=%d)",
|
|
lengths[len(lengths) // 2], low_chars, high_chars, len(lengths),
|
|
)
|
|
|
|
requested_meta = args.meta_column or [c for c in META_COLUMNS if c in columns]
|
|
missing_meta = [c for c in requested_meta if c not in columns]
|
|
if missing_meta:
|
|
raise SystemExit(f"프롬프트 소재 컬럼이 없습니다: {missing_meta}")
|
|
usable_meta = assert_no_body_columns(requested_meta)
|
|
if not usable_meta:
|
|
raise SystemExit(
|
|
f"소재로 쓸 컬럼이 없습니다. 기대: {META_COLUMNS} / 실제: {columns}"
|
|
)
|
|
logger.info("프롬프트 소재 컬럼: %s", usable_meta)
|
|
|
|
group_salt = os.environ.get(args.group_salt_env, "")
|
|
if args.group_column:
|
|
if args.group_column not in columns:
|
|
raise SystemExit(f"그룹 컬럼이 없습니다: {args.group_column}")
|
|
if not group_salt:
|
|
raise SystemExit(
|
|
f"--group-column 사용 시 {args.group_salt_env} 환경변수가 필요합니다."
|
|
)
|
|
|
|
models = args.model or ["gpt-4o-mini"]
|
|
if len(models) == 1 and not args.dry_run:
|
|
logger.warning(
|
|
"생성 모델이 1개입니다. 그 모델만 잡는 판별기가 됩니다. "
|
|
"--model 을 2개 이상 지정하세요."
|
|
)
|
|
|
|
done = load_done_ids(args.out)
|
|
if done:
|
|
logger.info("기존 출력 %d건을 건너뜁니다: %s", len(done), args.out)
|
|
target_new = max(0, args.limit - len(done))
|
|
|
|
rng = random.Random(args.seed)
|
|
# 소재 행을 결정적으로 섞는다. 같은 seed 면 같은 순서가 나온다.
|
|
order = sorted(
|
|
(
|
|
i for i in range(len(rows))
|
|
if _stable_int(f"shard:{i}") % args.shard_count == args.shard_index
|
|
),
|
|
key=lambda i: _stable_int(f"{args.seed}:{i}"),
|
|
)
|
|
logger.info("소재 샤드 %d/%d: %d행", args.shard_index, args.shard_count, len(order))
|
|
|
|
client = None
|
|
if not args.dry_run:
|
|
from openai import OpenAI
|
|
|
|
api_key = os.environ.get("OPENAI_API_KEY")
|
|
if not api_key and not args.base_url:
|
|
raise SystemExit("OPENAI_API_KEY 가 없습니다. --dry-run 으로 먼저 확인하세요.")
|
|
client = OpenAI(api_key=api_key or "local", base_url=args.base_url, max_retries=2)
|
|
|
|
target_pool = [n for n in lengths if low_chars <= n <= high_chars]
|
|
written = skipped_len = skipped_quality = failed = 0
|
|
attempted = 0
|
|
|
|
# dry-run 은 파일시스템도 건드리지 않는다. 빈 출력 파일이 남으면 다음 실행이
|
|
# "이미 돌린 것"으로 오해된다.
|
|
if args.dry_run:
|
|
sink = contextlib.nullcontext(None)
|
|
else:
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
sink = args.out.open("a", encoding="utf-8")
|
|
|
|
def prepare(idx: int):
|
|
nonlocal attempted
|
|
prompt_id = hashlib.sha1(f"{args.xlsx.name}:{idx}".encode()).hexdigest()[:16]
|
|
if prompt_id in done:
|
|
return None
|
|
meta = {
|
|
c: sanitize_prompt_metadata(str(rows[idx].get(c)))
|
|
for c in usable_meta
|
|
if isinstance(rows[idx].get(c), str) and str(rows[idx].get(c)).strip()
|
|
}
|
|
if not meta:
|
|
return None
|
|
source_group = f"ai-topic:{prompt_id}"
|
|
if args.group_column:
|
|
raw_group = str(rows[idx].get(args.group_column) or "").strip()
|
|
if raw_group and raw_group.lower() != "nan":
|
|
source_group = pseudonymous_id(raw_group, group_salt)
|
|
target = rng.choice(target_pool)
|
|
style = STYLE_VARIANTS[_stable_int(prompt_id) % len(STYLE_VARIANTS)]
|
|
model = models[attempted % len(models)]
|
|
prompt = _build_prompt(meta, target, style)
|
|
attempted += 1
|
|
return prompt_id, meta, source_group, target, style, model, prompt
|
|
|
|
def generate(task):
|
|
prompt_id, _, _, _, _, model, prompt = task
|
|
try:
|
|
response = client.chat.completions.create(
|
|
model=model,
|
|
temperature=args.temperature,
|
|
max_tokens=args.max_tokens,
|
|
messages=[
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
)
|
|
return task, (response.choices[0].message.content or "").strip(), None
|
|
except Exception as exc: # noqa: BLE001 — 개별 실패는 기록 후 계속
|
|
logger.warning("생성 실패 (%s, %s): %s", model, prompt_id, exc)
|
|
return task, "", exc
|
|
|
|
tasks = filter(None, (prepare(idx) for idx in order))
|
|
with sink as sink:
|
|
if args.dry_run:
|
|
for task in tasks:
|
|
if written >= target_new:
|
|
break
|
|
_, _, _, target, _, model, prompt = task
|
|
print(f"\n--- [{written + 1}] model={model} target={target}자 ---")
|
|
print(prompt)
|
|
written += 1
|
|
else:
|
|
with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
|
|
while written < target_new:
|
|
batch = []
|
|
for _ in range(min(args.concurrency, target_new - written)):
|
|
task = next(tasks, None)
|
|
if task is not None:
|
|
batch.append(task)
|
|
if not batch:
|
|
break
|
|
for task, text, error in executor.map(generate, batch):
|
|
prompt_id, meta, source_group, target, style, model, _ = task
|
|
if error is not None:
|
|
failed += 1
|
|
continue
|
|
text = normalize_ocr(text)
|
|
if not (low_chars <= len(text) <= high_chars):
|
|
skipped_len += 1
|
|
continue
|
|
if _hangul_ratio(text) < args.min_hangul_ratio:
|
|
skipped_quality += 1
|
|
continue
|
|
sink.write(json.dumps({
|
|
"text": text,
|
|
"generator": model,
|
|
"book": "",
|
|
"prompt_id": prompt_id,
|
|
"target_chars": target,
|
|
"char_count": len(text),
|
|
"style": style,
|
|
"source_group": source_group,
|
|
"generation_type": "pure_ai",
|
|
"provenance": "ai_generated_controlled",
|
|
"temperature": args.temperature,
|
|
"shard_index": args.shard_index,
|
|
"shard_count": args.shard_count,
|
|
"meta": meta,
|
|
}, ensure_ascii=False) + "\n")
|
|
sink.flush()
|
|
written += 1
|
|
if written % 50 == 0:
|
|
logger.info(
|
|
"생성 %d/%d (길이 폐기 %d, 품질 폐기 %d, 실패 %d)",
|
|
len(done) + written, args.limit,
|
|
skipped_len, skipped_quality, failed,
|
|
)
|
|
|
|
logger.info(
|
|
"완료: %d건 생성 / 길이 폐기 %d건 / 품질 폐기 %d건 / 호출 실패 %d건 → %s",
|
|
written, skipped_len, skipped_quality, failed, args.out,
|
|
)
|
|
if args.dry_run:
|
|
logger.info("dry-run 이라 API 를 호출하지 않았습니다. 비용 0.")
|
|
elif skipped_len > written * 0.3:
|
|
logger.warning(
|
|
"폐기율이 %.0f%% 로 높습니다. 모델이 분량 지시를 안 따르고 있습니다. "
|
|
"프롬프트의 분량 문구를 조정하거나 다른 모델을 쓰세요.",
|
|
skipped_len / max(1, skipped_len + written) * 100,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|