"""요약기 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())