o2o-plagiarism-ai/scripts/bench_summarizer.py
hbyang 8ae52b0a82 feat: add summary (No.7) consensus and grounded generation trials
요약 성능지표(No.7, ROUGE 65점) 실험 일습을 커밋한다.

- app/engine/summary_consensus.py: 후보 5개 생성 후 합의 선택(select_consensus).
  문자/어절 2-gram 가중 합의로 고르며, 유효 후보가 없으면 valid=False 를 낸다.
- app/engine/summary_grounded.py: 근거 추출 후 압축하는 2단계 생성.
- summarizer.py: 추출 전략 3종(textrank/coverage/lead). coverage 는 MMR 로
  중복 문장을 눌러 문서 전체를 넓게 담는다. 기본값은 textrank 로 유지한다.

스크립트는 생성·평가·검증을 분리했다. verify_* 는 API 호출 없이 저장된 산출물만
재계산하는 독립 검증기라 지표를 공용 함수로 합치지 않는다. 합치면 검증이 성립하지
않는다. tune_summarizer 는 사람 검수 참조(build_summary_annotation_packet ->
export_summary_annotations)를 입력으로 받아 선택과 최종 보고를 분리한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 10:02:04 +09:00

152 lines
7.1 KiB
Python

"""요약기 A/B 내부 벤치마크 — 추출 요약 vs LLM 추상 요약.
성능지표 #7 은 사람 참조 요약 대비 ROUGE recall 로 측정해야 한다. 아직 정답셋이
없어 본 평가는 할 수 없다. 이 스크립트는 그 전에 "LLM 훅을 켜면 수치가 오르는가"
만 판단하기 위한 내부 비교다.
참조 : 별도 모델(gpt-4o) 이 작성한 요약 — 은(silver) 기준
시스템 : ① TextRank 추출 요약 ② LLM 추상 요약
**편향 주의** — 참조가 LLM 산출물이므로 LLM 추상 요약 쪽에 유리하다. 두 방식의
상대 격차를 보는 용도이며, 이 수치를 성적서에 쓰면 안 된다.
입력은 전기(傳記) 본문을 쓴다. 전 차수 요약 시험과 같은 계열 자료이고 출판물이라
개인 자서전을 외부 API 로 보내지 않는다.
"""
from __future__ import annotations
import argparse
import glob
import json
import random
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
def lemmas(text: str) -> list[str]:
"""형태소 원형 열. 조사·어미 변형을 흡수해 어절 단위보다 느슨하게 맞춘다."""
from app.engine.structural import extract_lemmas
return extract_lemmas(text)
def ngrams(tokens: list[str], n: int) -> Counter:
return Counter(tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1))
def rouge_recall(reference: str, hypothesis: str, n: int = 2) -> float:
"""계획서 수식 기준 — 분모가 참조 n-gram 수인 recall."""
ref = ngrams(reference.split(), n)
hyp = ngrams(hypothesis.split(), n)
total = sum(ref.values())
if not total:
return 0.0
overlap = sum(min(count, hyp.get(gram, 0)) for gram, count in ref.items())
return overlap / total
def load_passages(pattern: str, count: int, seed: int) -> list[str]:
passages = []
for path in sorted(glob.glob(pattern)):
data = json.loads(Path(path).read_text(encoding="utf-8"))
for row in data.get("results", []):
text = (row.get("source_text") or "").strip()
if 400 <= len(text) <= 3000:
passages.append(text)
random.Random(seed).shuffle(passages)
return passages[:count]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--glob", default="/w/ai_publish/data_output4/*.json")
parser.add_argument("--count", type=int, default=30)
parser.add_argument("--seed", type=int, default=20260916)
parser.add_argument("--ref-model", default="gpt-4o")
parser.add_argument("--sys-model", default="gpt-4o-mini")
parser.add_argument("--multi-ref", type=int, default=1,
help="참조 개수. 2 이상이면 다중 참조 중 최고값으로 채점 (계획서 전제)")
parser.add_argument("--length-matched", action="store_true",
help="시스템 요약 길이를 참조 규격(원문의 25~35%)에 맞춘다")
parser.add_argument("--out", type=Path, default=Path("reports/summary_bench.json"))
args = parser.parse_args()
from openai import OpenAI
from app.core.config import get_settings
from app.engine.summarizer import Summarizer
get_settings.cache_clear()
settings = get_settings()
client = OpenAI(api_key=settings.openai_api_key)
summarizer = Summarizer(settings)
passages = load_passages(args.glob, args.count, args.seed)
print("본문 %d건 (전기)" % len(passages))
if not passages:
parser.error("본문을 찾지 못했습니다. --glob 확인")
def ask(model: str, prompt: str, text: str) -> str:
response = client.chat.completions.create(
model=model, temperature=0.2,
messages=[{"role": "system", "content": "당신은 한국어 요약 전문가입니다. 원문에 없는 사실을 만들지 마십시오."},
{"role": "user", "content": prompt + "\n\n" + text}])
return (response.choices[0].message.content or "").strip()
REF_PROMPT = ("다음 글을 한국어 줄글로 요약하십시오. 원문 분량의 25~35% 길이로, "
"핵심 인물·사건·시간·장소·인과를 보존하고 목록이나 제목은 쓰지 마십시오.")
# recall 은 참조를 얼마나 덮었는지를 재므로, 시스템 요약이 참조보다 지나치게
# 짧으면 구조적으로 손해를 본다. 길이 규격을 참조와 맞춘다.
SYS_PROMPT = (REF_PROMPT if args.length_matched else
"다음 글을 한국어 줄글로 간결하게 요약하십시오. 원문에 없는 내용을 넣지 마십시오.")
rows = []
for index, text in enumerate(passages, start=1):
# 다중 참조: 계획서가 전제하는 방식. 참조마다 채점해 최고값을 취한다.
references = [ask(args.ref_model, REF_PROMPT, text) for _ in range(args.multi_ref)]
extractive = summarizer.summarize(text, ratio=0.3, use_abstractive=False).final
abstractive = ask(args.sys_model, SYS_PROMPT, text)
row = {"index": index,
"reference_len": sum(len(r) for r in references) // len(references),
"system_len": len(abstractive)}
for label, system in (("extractive", extractive), ("abstractive", abstractive)):
for n in (1, 2):
row["%s_r%d" % (label, n)] = max(
rouge_recall(reference, system, n) for reference in references)
row["%s_r%d_lemma" % (label, n)] = max(
rouge_recall(" ".join(lemmas(reference)), " ".join(lemmas(system)), n)
for reference in references)
rows.append(row)
if index % 10 == 0:
print(" 진행 %d/%d" % (index, len(passages)))
def mean(key: str) -> float:
return sum(r[key] for r in rows) / len(rows)
print("\n" + "=" * 62)
print("ROUGE recall — 지표 정의별 (은 기준 참조 대비, 내부 비교용)")
print(" %-22s %-12s %-12s %s" % ("정의", "① 추출", "② LLM 추상", "차이"))
for label, key in (("ROUGE-1 (어절)", "r1"), ("ROUGE-2 (어절)", "r2"),
("ROUGE-1 (형태소)", "r1_lemma"), ("ROUGE-2 (형태소)", "r2_lemma")):
a, b = mean("extractive_" + key), mean("abstractive_" + key)
print(" %-22s %-12.4f %-12.4f %+.4f" % (label, a, b, b - a))
extractive_score = mean("extractive_r2")
abstractive_score = mean("abstractive_r2")
print("\n※ 참조가 LLM 산출물이라 ②에 유리한 편향이 있습니다. 성적서용 수치가 아닙니다.")
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps({
"note": "내부 A/B. 참조는 %s 산출물(은 기준)이며 성적서용이 아님." % args.ref_model,
"count": len(rows), "ref_model": args.ref_model, "sys_model": args.sys_model,
"extractive": extractive_score, "abstractive": abstractive_score,
"rows": rows,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print("%s 에 기록" % args.out)
return 0
if __name__ == "__main__":
raise SystemExit(main())