"""표절 페어 학습/평가 데이터 생성 스크립트. 레퍼런스 코퍼스의 각 원본을 5가지 변형으로 자동 생성: 1. paraphrase - 어휘만 바꾼 패러프레이즈 (표면적 표절) 2. character_swap - 인물 이름만 치환 (인물 차용) 3. plot_only - 인물/배경 다른 작품 스타일, 플롯만 동일 (플롯 차용) 4. summary - 요약/축약 (요약형 표절) 5. legitimate - 동일 주제/장르의 다른 이야기 (정상, 비표절 음성 샘플) 출력: data/training/pairs.jsonl {"pair_id": "...", "source_doc": "ref-0001", "transformation": "paraphrase", "is_plagiarism": true, "original_excerpt": "...", "derived_text": "..."} 자체 평가 데이터셋(계획서 성능지표 #4) 용도. 운영 모델 학습 시 이 데이터와 컴북스 보유 30,000 건 원천 자료를 결합하여 정밀도 95% 달성. 사용: export OPENAI_API_KEY=sk-... python scripts/generate_plagiarism_pairs.py --pairs-per-doc 3 --out data/training/pairs.jsonl """ from __future__ import annotations import argparse import json import logging import os import sys import uuid from dataclasses import dataclass from pathlib import Path logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") logger = logging.getLogger("gen-pairs") # 프로젝트 루트에서 실행되도록 path 설정 ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from app.engine.corpus import ReferenceDoc, load_corpus # noqa: E402 TRANSFORMATIONS = { "paraphrase": { "is_plagiarism": True, "instruction": ( "원문의 의미와 사건 전개를 유지하면서, 모든 문장을 다른 표현으로 다시 써라. " "어휘를 60% 이상 교체하고, 문장 구조를 바꾸어 패러프레이즈를 생성하라. " "원문의 인물 이름과 핵심 모티프는 유지하라." ), }, "character_swap": { "is_plagiarism": True, "instruction": ( "원문의 모든 등장인물 고유명사를 다른 이름으로 치환하라. " "스토리, 사건, 배경은 그대로 유지하라. 인물 성격 묘사는 동일하게 둔다." ), }, "plot_only": { "is_plagiarism": True, "instruction": ( "원문의 플롯 구조와 사건 순서는 동일하게 유지하되, " "인물 이름·시대 배경·지명·소재를 완전히 다른 세계관으로 바꿔 다시 써라." ), }, "summary": { "is_plagiarism": True, "instruction": ( "원문을 1/3 분량으로 요약하라. 핵심 사건과 인물 이름은 유지하되, " "문장 표현은 새로 작성하라." ), }, "legitimate": { "is_plagiarism": False, "instruction": ( "원문과 동일한 장르·주제(모티프)이지만, " "완전히 다른 인물·배경·플롯의 새로운 짧은 이야기를 창작하라. " "원문의 인물 이름이나 사건은 절대 사용하지 마라." ), }, } @dataclass class Pair: pair_id: str source_doc: str source_title: str transformation: str is_plagiarism: bool original_excerpt: str derived_text: str def _generate_one(client, model: str, doc: ReferenceDoc, transformation: str) -> Pair | None: cfg = TRANSFORMATIONS[transformation] prompt = ( f"[작업 지시]\n{cfg['instruction']}\n\n" f"[원문]\n{doc.text}\n\n" f"[출력 형식]\n결과 텍스트만 출력. 추가 설명 금지." ) try: resp = client.chat.completions.create( model=model, temperature=0.7, messages=[ {"role": "system", "content": "You are a Korean creative writer producing controlled text variations."}, {"role": "user", "content": prompt}, ], ) derived = (resp.choices[0].message.content or "").strip() if not derived: return None return Pair( pair_id=str(uuid.uuid4()), source_doc=doc.doc_id, source_title=doc.title, transformation=transformation, is_plagiarism=cfg["is_plagiarism"], original_excerpt=doc.text, derived_text=derived, ) except Exception as exc: logger.warning("Generation failed for %s/%s: %s", doc.doc_id, transformation, exc) return None def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--corpus-dir", default=str(ROOT / "data/reference")) parser.add_argument("--out", default=str(ROOT / "data/training/pairs.jsonl")) parser.add_argument("--model", default=os.environ.get("OPENAI_PAIR_MODEL", "gpt-4o-mini")) parser.add_argument("--pairs-per-doc", type=int, default=1, help="문서당 각 변형 유형 몇 번 생성할지 (총=유형5 × pairs-per-doc)") parser.add_argument("--limit-docs", type=int, default=0, help="처음 N개 문서만 처리 (0 = 전체)") parser.add_argument("--transformations", nargs="+", default=list(TRANSFORMATIONS.keys()), choices=list(TRANSFORMATIONS.keys())) args = parser.parse_args() api_key = os.environ.get("OPENAI_API_KEY", "").strip() if not api_key: logger.error("OPENAI_API_KEY 환경변수가 필요합니다.") return 1 try: from openai import OpenAI except ImportError: logger.error("openai 패키지가 필요합니다. pip install openai") return 1 client = OpenAI(api_key=api_key) docs = load_corpus(Path(args.corpus_dir).resolve()) if not docs: logger.error("코퍼스가 비어있음: %s", args.corpus_dir) return 1 if args.limit_docs > 0: docs = docs[: args.limit_docs] out_path = Path(args.out) out_path.parent.mkdir(parents=True, exist_ok=True) total = 0 plagiarism = 0 with out_path.open("w", encoding="utf-8") as f: for doc in docs: for transformation in args.transformations: for _ in range(args.pairs_per_doc): pair = _generate_one(client, args.model, doc, transformation) if pair is None: continue f.write(json.dumps(pair.__dict__, ensure_ascii=False) + "\n") total += 1 if pair.is_plagiarism: plagiarism += 1 logger.info( "[%d] %s / %s (plagiarism=%s, %d chars)", total, doc.doc_id, transformation, pair.is_plagiarism, len(pair.derived_text), ) logger.info("=" * 60) logger.info("DONE: %d pairs (plagiarism=%d, legitimate=%d)", total, plagiarism, total - plagiarism) logger.info("Output: %s", out_path) return 0 if __name__ == "__main__": raise SystemExit(main())