o2o-plagiarism-ai/scripts/generate_plagiarism_pairs.py

190 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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