diff --git a/docs/TEST_PLAN_2026_PHASE2.md b/docs/TEST_PLAN_2026_PHASE2.md
index cfe409d..04a3ed7 100644
--- a/docs/TEST_PLAN_2026_PHASE2.md
+++ b/docs/TEST_PLAN_2026_PHASE2.md
@@ -3,7 +3,7 @@
> 작성: 에이아이오투오 / 2026-09-16
> 서식: 구미전자정보기술원 `성능평가확인서_출판과제_1029` 동일 구성
> 선행 시험: `GERIR.CE.2511-02.009` (2025-11-19, 3개 항목 PASS)
-> **범위: 요약 성능(성능지표 #7) 제외 — 2개 항목**
+> **범위: 3개 항목.** 요약 성능(#7)은 정답셋 미구축으로 서식과 절차만 확정한 상태다.
>
> 성적서 서식 그대로 옮겨 적을 수 있도록 구성했다. 각 절의 번호·표 구성은
> 원본 서식과 일치하며, 수치와 데이터만 2-1년차 측정값으로 교체하였다.
@@ -18,7 +18,7 @@
| 주소 | (13453) 경기도 성남시 수정구 금토로 32 ㈜KT 판교빌딩 East 504호 ~ 505호 |
| 시험품 | 출판콘텐츠 분석 및 공유 기술용 AI 모델 |
| 성적서 용도 | 출판환경 변화 대응을 위한 생성형 AI 기반 출판 콘텐츠 분석 및 공유 플랫폼 기술 개발과제 성능 평가 |
-| 시험 항목 | **2개 항목** |
+| 시험 항목 | **3개 항목** |
| 시험 장소 | 에이아이오투오 판교 연구소 |
| 시험 결과 | 붙임(시험결과) 참조 |
@@ -47,7 +47,7 @@
### 2.1 시험 규격
- **2.1.1 시험규격** : 시험절차서 기준
-- **2.1.2 시험항목** : 시험절차서 **2개 항목**
+- **2.1.2 시험항목** : 시험절차서 **3개 항목**
### 2.2 시험 장비 정보
@@ -70,7 +70,7 @@
| 구분 | 시스템 사양 | 운영 SW 및 시험도구 | 구현 모듈 |
|---|---|---|---|
| 시험 PC | CPU : Apple M3 pro
RAM : 18GB | SSH 터미널 | 없음 |
-| GPU서버 | GPU : RTX 3090 24G × 2
CPU : Intel i9-12900KS (24core)
RAM : 125GB | Ubuntu 24.0.2
Pytorch 2.8
Python 3.9
Cuda 12.2 | 메타 데이터 추출 모듈,
표절 여부 판별 모듈 |
+| GPU서버 | GPU : RTX 3090 24G × 2
CPU : Intel i9-12900KS (24core)
RAM : 125GB | Ubuntu 24.0.2
Pytorch 2.8
Python 3.9
Cuda 12.2 | 메타 데이터 추출 모듈,
표절 여부 판별 모듈,
요약 모듈 |
#### 2.2.3 평가방법
@@ -78,6 +78,7 @@
|---|---|---|---|
| 1 | 메타 데이터 추출
(F1 – score) | KLUE 데이터셋의 NER 데이터셋을 활용하여, 21,008개의 학습 데이터로 개발된 모델을 학습시키고, 5,000개의 테스트 데이터를 활용하여 개발된 모델에 대한 상대 평가 진행 | 과제 성능지표 3번에 해당 |
| 2 | 표절 여부 판별 정밀도
(precision) | 자체 제작된 실제 표절 글과, 표절이 아닌 글을 Classification하여, precision 계산
(시험 데이터 1,000건 = 표절 500건 + 비표절 500건. 모델이 학습하지 않은 작성자의 자료로 구성) | 과제 성능지표 4번에 해당 |
+| 3 | 요약 성능
(N-gram ROUGE score) | 자체 제작된 요약 데이터셋 300건을, 요약 모델에 테스트하여 N-gram ROUGE score(ROUGE-1 recall) 계산 | 과제 성능지표 7번에 해당 |
---
@@ -89,6 +90,7 @@
|---|---|---|---|
| 메타 데이터 추출(F1-score) | 83% | **83.77** | 83.77% 달성 |
| 표절 여부 판별 정밀도(Precision) | 97% | **98.40** | 98.40% 달성 |
+| 요약 성능 (N-gram ROUGE score) | 65% | (미측정) | 정답셋 구축 후 측정 |
### 3.2 항목별 시험결과
@@ -590,6 +592,174 @@ f1 = 2 * precision * recall / (precision + recall) if (precision + recall
실제 운영 조건을 반영하고 있음을 확인하였다. 완전복제·문장재배열·축약은
미탐지 0건이며, 어휘치환 150건 중 6건만 놓쳤다.
+---
+
+## 3.2.3 요약 성능
+
+> **미측정 항목** — 요약 정답셋이 구축되지 않아 결과를 채울 수 없다.
+> 아래는 서식과 시현 절차를 확정해 둔 것이며, 정답셋 수령 후 같은 명령으로
+> 수치가 채워진다. 접수 전 결정이 필요한 사항은 3.2.3.4 항에 정리했다.
+
+### 3.2.3.1 시험방법
+
+```
+① 자체 제작된 요약 데이터셋 준비
+② 요약 모델에 학습되지 않은 데이터 300건에 대해 모델 요약 진행
+③ 생성된 결과물에 대한 N-gram ROUGE score(ROUGE-1 recall) 측정
+```
+
+> **전 차수 대비 수정 두 곳**
+> - ② **1,000건 → 300건**. 사람이 작성한 요약 정답이 필요한 항목이라 1,000건은
+> 구축 비용이 과다하다. 통계적 신뢰구간을 확보하면서 현실적인 규모로 300건을 잡는다.
+> - ③ **지표 정의 명시**. 계획서 p.24 수식의 분모가 참조 n-gram 수이므로
+> 해당 지표는 **recall** 이다. 전 차수 코드는 f1 을 반환했다. 어느 값을
+> 보고하는지 성적서에 남긴다.
+
+### 3.2.3.2 시험결과
+
+| 시험항목 | 목표치 | 결과 | 비고 |
+|---|---|---|---|
+| 요약 성능 (N-gram ROUGE score) | 65% | (미측정) | 정답셋 구축 후 측정 |
+
+### 3.2.3.3 성능 평가 과정
+
+#### 1. 데이터 준비
+
+```bash
+docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \
+ python scripts/show_summary_samples.py data/eval/summary.jsonl \
+ --head 2 --tail 2 --max-chars 300 --with-reference
+```
+
+출력 형식:
+
+```json
+1. 데이터 준비
+ {
+ "index": 1,
+ "original": "(원문 …)",
+ "reference": "(사람 작성 요약 정답 …)",
+ },
+ {
+ "index": 2,
+ "original": "(원문 …)",
+ "reference": "(사람 작성 요약 정답 …)",
+ },
+...
+ {
+ "index": 300,
+ "original": "(원문 …)",
+ "reference": "(사람 작성 요약 정답 …)",
+ },
+
+총 데이터 300건
+```
+
+#### 2. 요약 생성
+
+```python
+# scripts/eval_rouge.py — system 요약이 없으면 자체 Summarizer 로 생성한다.
+summarizer = get_summarizer()
+
+pairs = []
+for row in rows:
+ # references(복수) 우선, 없으면 reference(단수)
+ raw = row.get("references") or row.get("reference", "")
+ reference = [raw] if isinstance(raw, str) else [r for r in raw if r and r.strip()]
+ if not reference:
+ continue
+ if "system" in row and row["system"]:
+ system = row["system"]
+ else:
+ system = summarizer.summarize(row.get("text", ""), ratio=args.ratio).final
+ pairs.append((system, reference))
+```
+
+#### 3. n-gram ROUGE 스코어 측정
+
+```python
+# app/engine/rouge.py
+def _prf(match: int, sys_total: int, ref_total: int) -> RougeScore:
+ precision = match / sys_total if sys_total else 0.0
+ recall = match / ref_total if ref_total else 0.0
+ f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
+ return RougeScore(precision, recall, f1)
+
+
+def evaluate_pairs(pairs, mode: str = "lemma") -> dict:
+ """(system, reference) 페어 → 코퍼스 평균 ROUGE-1/2/L.
+
+ **목표와 대조하는 값은 recall** 이다(계획서 수식의 분모가 참조 n-gram 수).
+ """
+ acc = {"rouge1": [], "rouge2": [], "rougeL": []}
+ for system, reference in pairs:
+ acc["rouge1"].append(rouge_n(system, reference, 1, mode))
+ acc["rouge2"].append(rouge_n(system, reference, 2, mode))
+ acc["rougeL"].append(rouge_l(system, reference, mode))
+ ...
+```
+
+실행:
+
+```bash
+docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \
+ python scripts/eval_rouge.py data/eval/summary.jsonl \
+ --out data/eval/summary_scorecard.json
+```
+
+#### 4. 결과
+
+```json
+{
+ "summary_performance": {
+ "model": "O2O Summarizer",
+ "test_dataset": {
+ "total_samples": 300,
+ "multi_reference_samples": 0,
+ "source": "data/eval/summary.jsonl"
+ },
+ "rouge_scores": {
+ "rouge1": { "precision": 0.0, "recall": 0.0, "f1": 0.0 },
+ "rouge2": { "precision": 0.0, "recall": 0.0, "f1": 0.0 },
+ "rougeL": { "precision": 0.0, "recall": 0.0, "f1": 0.0 }
+ },
+ "reported_metric": "rouge1_recall",
+ "performance_metrics": {
+ "n_gram_rouge_score": 0.0,
+ "target": 0.65,
+ "achieved": false
+ },
+ "settings": { "tokenization": "lemma", "summary_ratio": 0.3 },
+ "interpretation": {
+ "n_gram_rouge_score": "참조 요약의 1-gram 중 0.0% 를 시스템 요약이 담아냄",
+ "note": "계획서 수식 분모가 참조 n-gram 수이므로 recall 을 보고한다."
+ }
+ }
+}
+```
+
+```
+최종 결과 : 평균 수치 __%
+```
+
+> 위 JSON 은 **형식 예시**이며 수치는 전부 자리표시자다. 정답셋 투입 후
+> 실제 값으로 대체한다.
+
+### 3.2.3.4 접수 전 결정 필요 사항
+
+| 항목 | 내용 |
+|---|---|
+| **정답셋 미구축** | 파일럿 20건·본 구축 300건 모두 미완. 이 항목의 유일한 선행 조건이다 |
+| **사람 상한 확인** | 본 구축 전 파일럿 20건으로 사람 간 일치도(IAA)를 측정해 상한을 먼저 본다(`eval_rouge.py --iaa`). 상한이 65 미만이면 300건 구축 전에 규격 조정을 협의해야 한다 |
+| **건수 변경** | 계획서·전 차수는 1,000건. 300건으로 줄이려면 시험기관 협의 필요 |
+| **지표 정의** | 계획서는 recall 수식, 전 차수 코드는 f1 반환. 정의를 확정해야 한다 |
+| **전 차수 방식 승계 불가** | 전 차수 로그에 `optimal_60_modification` / `success_rate 0.15` 기록이 있다. 목표치에 맞춰 요약문을 수정한 것으로 보여 그대로 승계하기 어렵다 |
+| **시험 환경** | 계획서가 No.7 에 A100 을 명시. 본 서버(RTX 3090)로 가능한지 별도 협의 필요 |
+
+> **내부 A/B 벤치는 성적서용이 아니다** — `reports/summary_bench.json`,
+> `summary_bench_v2.json` 은 참조가 gpt-4o 산출물(은 기준)이고 30건 규모다.
+> 파일 자체에 "성적서용이 아님" 이 기록돼 있다. 사람 작성 정답셋과는 다르다.
+
---
---
diff --git a/scripts/eval_rouge.py b/scripts/eval_rouge.py
index f592851..5ec7bc2 100644
--- a/scripts/eval_rouge.py
+++ b/scripts/eval_rouge.py
@@ -115,11 +115,53 @@ def _run_iaa(rows: list[dict], mode: str) -> None:
print("여유가 충분합니다. 이 규격으로 본 구축을 진행해도 됩니다.")
+def build_scorecard(scores: dict, count: int, mode: str, ratio: float,
+ multi_ref_rows: int, dataset: str | None) -> dict:
+ """성적서 "4. 결과" 게재 형식으로 정리한다.
+
+ 보고 대상은 **ROUGE-1 recall** 이다. 계획서 p.24 수식의 분모가 참조 n-gram
+ 수이므로 recall 이 지표에 해당한다. f1 은 참고로만 남긴다. 전 차수 코드가
+ f1 을 반환한 탓에 정의가 어긋나 있어, 어느 값을 보고하는지 명시한다.
+ """
+ r1 = scores["rouge1"]
+ target = 0.65
+ return {
+ "summary_performance": {
+ "model": "O2O Summarizer",
+ "test_dataset": {
+ "total_samples": count,
+ "multi_reference_samples": multi_ref_rows,
+ "source": dataset or "(내장 dry-run 샘플)",
+ },
+ "rouge_scores": {
+ "rouge1": scores["rouge1"],
+ "rouge2": scores["rouge2"],
+ "rougeL": scores["rougeL"],
+ },
+ "reported_metric": "rouge1_recall",
+ "performance_metrics": {
+ "n_gram_rouge_score": r1["recall"],
+ "target": target,
+ "achieved": r1["recall"] >= target,
+ },
+ "settings": {"tokenization": mode, "summary_ratio": ratio},
+ "interpretation": {
+ "n_gram_rouge_score":
+ "참조 요약의 1-gram 중 %.1f%% 를 시스템 요약이 담아냄"
+ % (r1["recall"] * 100),
+ "note": "계획서 수식 분모가 참조 n-gram 수이므로 recall 을 보고한다. "
+ "ROUGE-1 F1 %.4f 는 계획서 지표가 아니다." % r1["f1"],
+ },
+ }
+ }
+
+
def main() -> None:
ap = argparse.ArgumentParser(description="요약 ROUGE 평가 (성능지표 No.7)")
ap.add_argument("dataset", nargs="?", default=None, help="JSONL 정답셋 (없으면 dry-run)")
ap.add_argument("--mode", choices=["lemma", "char"], default="lemma", help="토큰화 방식")
ap.add_argument("--ratio", type=float, default=0.3, help="자체 요약 길이 비율")
+ ap.add_argument("--out", help="scorecard JSON 저장 경로")
ap.add_argument("--iaa", action="store_true",
help="사람 상한 측정 — 참조 2개 이상인 행에서 작성자끼리 채점")
args = ap.parse_args()
@@ -169,8 +211,20 @@ def main() -> None:
print(f" (참고: ROUGE-1 F1 = {scores['rouge1']['f1']:.4f} — 계획서 지표 아님)")
if multi_ref_rows:
print(f" 다중 참조 사용: {multi_ref_rows}/{len(pairs)}건")
+ scorecard = build_scorecard(scores, len(pairs), args.mode, args.ratio,
+ multi_ref_rows, args.dataset)
+ rendered = json.dumps(scorecard, ensure_ascii=False, indent=2)
+ print("\n" + "=" * 62)
+ print("4. 결과")
+ print()
+ print(rendered)
+ print("\n최종 결과 : 평균 수치 %.2f%%" % (r1 * 100))
+ if args.out:
+ Path(args.out).write_text(rendered + "\n", encoding="utf-8")
+ print("\n%s 에 결과 기록" % args.out)
+
if is_dry:
- print("\n※ 이는 파이프라인 검증용 dry-run 수치입니다. 컴북스 요약 정답셋 수령 후 본 평가로 정식 측정.")
+ print("\n※ 이는 파이프라인 검증용 dry-run 수치입니다. 요약 정답셋 수령 후 본 평가로 정식 측정.")
if __name__ == "__main__":
diff --git a/scripts/show_summary_samples.py b/scripts/show_summary_samples.py
new file mode 100644
index 0000000..c6f1b0b
--- /dev/null
+++ b/scripts/show_summary_samples.py
@@ -0,0 +1,95 @@
+"""성능지표 #7 요약 정답셋 예시를 성적서 형식으로 출력한다.
+
+전 차수 성적서(GERI `GERIR.CE.2511-02.009`) 3.2.3.3 "1. 데이터 준비" 는
+원문을 `{index, original}` 형태로 앞뒤 몇 건만 싣고 중간을 `...` 로 생략한 뒤
+총 건수를 적는다. 그 형식으로 낸다.
+
+입력은 `scripts/eval_rouge.py` 와 같은 JSONL 이다.
+ {"text": "원문 ...", "reference": "사람 작성 요약 ..."}
+ {"text": "원문 ...", "references": ["정답 1 ...", "정답 2 ..."]}
+
+`--with-reference` 를 주면 정답 요약도 함께 싣는다.
+
+사용:
+ python scripts/show_summary_samples.py data/eval/summary.jsonl
+ python scripts/show_summary_samples.py data/eval/summary.jsonl --head 2 --tail 2
+"""
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+
+def record(index: int, row: dict, max_chars: int | None,
+ with_reference: bool) -> list[str]:
+ def clean(value: str) -> str:
+ body = " ".join(value.split())
+ if max_chars and len(body) > max_chars:
+ body = body[:max_chars] + " …"
+ return body
+
+ lines = [" {", ' "index": %d,' % index,
+ ' "original": "%s",' % clean(row.get("text", ""))]
+ if with_reference:
+ raw = row.get("references") or row.get("reference", "")
+ references = [raw] if isinstance(raw, str) else list(raw)
+ if len(references) == 1:
+ lines.append(' "reference": "%s",' % clean(references[0]))
+ else:
+ lines.append(' "references": [')
+ for n, reference in enumerate(references):
+ comma = "," if n < len(references) - 1 else ""
+ lines.append(' "%s"%s' % (clean(reference), comma))
+ lines.append(" ],")
+ lines.append(" },")
+ return lines
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("dataset", type=Path, help="JSONL 정답셋")
+ parser.add_argument("--head", type=int, default=2, help="앞에서 N건")
+ parser.add_argument("--tail", type=int, default=2, help="뒤에서 N건")
+ parser.add_argument("--max-chars", type=int, default=None,
+ help="본문 길이 제한 (미지정 시 전문)")
+ parser.add_argument("--with-reference", action="store_true",
+ help="정답 요약도 함께 출력")
+ parser.add_argument("--out", help="파일로 저장")
+ args = parser.parse_args()
+
+ if not args.dataset.exists():
+ raise SystemExit(
+ "정답셋이 없습니다: %s\n"
+ "성능지표 #7 은 요약 정답셋 구축 후 측정 가능하다. "
+ "docs/TEST_PLAN_2026_PHASE2.md 부록 E 참조." % args.dataset)
+
+ rows = [json.loads(line) for line
+ in args.dataset.read_text(encoding="utf-8").splitlines() if line.strip()]
+ total = len(rows)
+
+ indices = list(range(min(args.head, total)))
+ tail_start = max(len(indices), total - args.tail)
+ indices += list(range(tail_start, total))
+
+ lines = ["1. 데이터 준비"]
+ previous = -1
+ for i in indices:
+ if previous >= 0 and i != previous + 1:
+ lines.append("...")
+ lines.extend(record(i + 1, rows[i], args.max_chars, args.with_reference))
+ previous = i
+ lines.append("")
+ lines.append("총 데이터 %d건" % total)
+
+ text = "\n".join(lines)
+ if args.out:
+ Path(args.out).write_text(text + "\n", encoding="utf-8")
+ print("%s 에 기록" % args.out)
+ else:
+ print(text)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())