#!/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", "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())