190 lines
6.9 KiB
Python
190 lines
6.9 KiB
Python
"""표절 페어 학습/평가 데이터 생성 스크립트.
|
||
|
||
레퍼런스 코퍼스의 각 원본을 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())
|