feat: 요약 검수 패킷과 수기집 원본 수집 도구 추가
This commit is contained in:
parent
9f62ed877a
commit
40eede2566
164
scripts/build_summary_annotation_packet.py
Normal file
164
scripts/build_summary_annotation_packet.py
Normal file
@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ROUGE 평가용 사람 다중 참조 요약 검수 패킷을 만든다."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.engine.summarizer import extractive_summary
|
||||
from app.engine.training_data import (
|
||||
pseudonymous_id,
|
||||
redact_direct_identifiers,
|
||||
sanitize_prompt_metadata,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("xlsx", type=Path)
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--text-column", default="에피소드 본문")
|
||||
parser.add_argument("--title-column", default="에피소드 제목")
|
||||
parser.add_argument("--group-column", default="id")
|
||||
parser.add_argument("--limit", type=int, default=300)
|
||||
parser.add_argument("--min-chars", type=int, default=500)
|
||||
parser.add_argument("--salt-env", default="DATA_ANONYMIZATION_SALT")
|
||||
parser.add_argument("--seed", type=int, default=20260820)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _stable_order(seed: int, key: str) -> str:
|
||||
return hashlib.sha256(f"{seed}:{key}".encode()).hexdigest()
|
||||
|
||||
|
||||
def select_group_diverse(rows: list[dict], limit: int, seed: int) -> list[dict]:
|
||||
"""모든 작성자에서 1건씩 먼저 선택한 후 남은 수를 채운다."""
|
||||
grouped: dict[str, list[dict]] = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped[row["source_group"]].append(row)
|
||||
for group_rows in grouped.values():
|
||||
group_rows.sort(key=lambda r: _stable_order(seed, r["row_key"]))
|
||||
|
||||
group_names = sorted(grouped, key=lambda g: _stable_order(seed, g))
|
||||
chosen: list[dict] = []
|
||||
depth = 0
|
||||
while len(chosen) < limit:
|
||||
added = False
|
||||
for group in group_names:
|
||||
if depth < len(grouped[group]):
|
||||
chosen.append(grouped[group][depth])
|
||||
added = True
|
||||
if len(chosen) >= limit:
|
||||
break
|
||||
if not added:
|
||||
break
|
||||
depth += 1
|
||||
return chosen
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
|
||||
salt = os.environ.get(args.salt_env, "")
|
||||
if not salt:
|
||||
print(f"{args.salt_env} 환경변수가 필요합니다.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source = load_workbook(args.xlsx, read_only=True, data_only=True)
|
||||
sheet = source.worksheets[0]
|
||||
iterator = sheet.iter_rows(values_only=True)
|
||||
headers = [str(v).strip() if v is not None else "" for v in next(iterator)]
|
||||
positions = {name: i for i, name in enumerate(headers)}
|
||||
missing = [c for c in (args.text_column, args.title_column, args.group_column) if c not in positions]
|
||||
if missing:
|
||||
print(f"필수 컬럼 없음: {missing}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
candidates = []
|
||||
for rownum, values in enumerate(iterator, start=2):
|
||||
text = str(values[positions[args.text_column]] or "").strip()
|
||||
raw_group = str(values[positions[args.group_column]] or "").strip()
|
||||
if len(text) < args.min_chars or not raw_group:
|
||||
continue
|
||||
text = redact_direct_identifiers(text)
|
||||
title = sanitize_prompt_metadata(
|
||||
str(values[positions[args.title_column]] or "").strip()
|
||||
)
|
||||
candidates.append({
|
||||
"row_key": f"{sheet.title}:{rownum}",
|
||||
"source_group": pseudonymous_id(raw_group, salt),
|
||||
"episode_title": title,
|
||||
"source_text": text,
|
||||
})
|
||||
source.close()
|
||||
|
||||
selected = select_group_diverse(candidates, args.limit, args.seed)
|
||||
if len(selected) < args.limit:
|
||||
print(f"후보가 부족합니다: {len(selected)}/{args.limit}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
workbook = Workbook()
|
||||
ws = workbook.active
|
||||
ws.title = "annotations"
|
||||
columns = [
|
||||
"annotation_id", "source_group", "episode_title", "source_text",
|
||||
"baseline_extractive", "reference_summary_1", "reviewer_1",
|
||||
"reference_summary_2", "reviewer_2", "status", "notes",
|
||||
]
|
||||
ws.append(columns)
|
||||
for index, row in enumerate(selected, start=1):
|
||||
baseline = extractive_summary(row["source_text"], ratio=0.3).final
|
||||
ws.append([
|
||||
f"summary-{index:04d}", row["source_group"], row["episode_title"],
|
||||
row["source_text"], baseline, "", "", "", "", "pending", "",
|
||||
])
|
||||
|
||||
header_fill = PatternFill("solid", fgColor="D9EAF7")
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = header_fill
|
||||
ws.freeze_panes = "A2"
|
||||
ws.auto_filter.ref = ws.dimensions
|
||||
widths = {"A": 16, "B": 34, "C": 30, "D": 90, "E": 70,
|
||||
"F": 70, "G": 18, "H": 70, "I": 18, "J": 14, "K": 30}
|
||||
for column, width in widths.items():
|
||||
ws.column_dimensions[column].width = width
|
||||
for row in ws.iter_rows(min_row=2):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
|
||||
guide = workbook.create_sheet("guidelines")
|
||||
guidance = [
|
||||
("목적", "ROUGE recall 65 평가를 위한 사람 참조 요약 2종 구축"),
|
||||
("독립 작성", "검수자 1·2는 서로의 요약을 보지 않고 작성한다."),
|
||||
("길이", "원문 문장 수의 약 25~35%. 필요하면 의미 유지 범위에서 조정한다."),
|
||||
("필수", "핵심 인물, 사건, 시간·장소, 원인과 결과를 보존한다."),
|
||||
("금지", "원문에 없는 사실·감정·평가를 추가하지 않는다."),
|
||||
("문체", "자연스런 한국어 줄글로 작성하고 목록·제목은 쓰지 않는다."),
|
||||
("완료", "두 요약과 검수자명을 입력한 후 status를 completed로 변경한다."),
|
||||
]
|
||||
for key, value in guidance:
|
||||
guide.append([key, value])
|
||||
guide.column_dimensions["A"].width = 18
|
||||
guide.column_dimensions["B"].width = 100
|
||||
for cell in guide[1]:
|
||||
cell.font = Font(bold=True)
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
workbook.save(args.out)
|
||||
print(f"저장: {args.out} / {len(selected)}건 / 작성자 그룹 {len({r['source_group'] for r in selected})}개")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
scripts/download_life_writing_drive.py
Normal file
92
scripts/download_life_writing_drive.py
Normal file
@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""수령한 생활 수기집 목록의 Google Drive 공유 원본을 다운로드한다."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
FILE_ID = re.compile(r"drive\.google\.com/file/d/([^/]+)")
|
||||
FOLDER_ID = re.compile(r"drive\.google\.com/drive/folders/([^/?]+)")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--out-dir", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def drive_kind(url: str) -> tuple[str, str] | None:
|
||||
if match := FILE_ID.search(url):
|
||||
return "file", match.group(1)
|
||||
if match := FOLDER_ID.search(url):
|
||||
return "folder", match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
import gdown
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
print("gdown과 openpyxl이 필요합니다.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
workbook = load_workbook(args.manifest, read_only=False, data_only=False)
|
||||
sheet = workbook.worksheets[0]
|
||||
results = []
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for rownum in range(3, sheet.max_row + 1):
|
||||
cell = sheet.cell(row=rownum, column=10)
|
||||
url = cell.hyperlink.target if cell.hyperlink else ""
|
||||
parsed = drive_kind(url or "")
|
||||
if not parsed:
|
||||
continue
|
||||
kind, drive_id = parsed
|
||||
target = args.out_dir / f"source_{rownum:03d}"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
result = {
|
||||
"row": rownum,
|
||||
"kind": kind,
|
||||
"drive_id": drive_id,
|
||||
"url": url,
|
||||
"label": str(cell.value or ""),
|
||||
"status": "failed",
|
||||
"files": [],
|
||||
}
|
||||
try:
|
||||
if kind == "folder":
|
||||
gdown.download_folder(
|
||||
url=url, output=str(target), quiet=False, remaining_ok=True,
|
||||
)
|
||||
else:
|
||||
gdown.download(id=drive_id, output=str(target) + "/", quiet=False)
|
||||
files = sorted(str(p.relative_to(args.out_dir)) for p in target.rglob("*") if p.is_file())
|
||||
result["files"] = files
|
||||
result["status"] = "downloaded" if files else "empty"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["error"] = str(exc)
|
||||
results.append(result)
|
||||
|
||||
report = {
|
||||
"manifest": args.manifest.name,
|
||||
"drive_links": len(results),
|
||||
"downloaded": sum(r["status"] == "downloaded" for r in results),
|
||||
"failed_or_empty": sum(r["status"] != "downloaded" for r in results),
|
||||
"results": results,
|
||||
}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({k: v for k, v in report.items() if k != "results"}, ensure_ascii=False))
|
||||
return 0 if report["downloaded"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
9
tests/test_download_life_writing_drive.py
Normal file
9
tests/test_download_life_writing_drive.py
Normal file
@ -0,0 +1,9 @@
|
||||
from scripts.download_life_writing_drive import drive_kind
|
||||
|
||||
|
||||
def test_drive_kind_parses_file_and_folder():
|
||||
assert drive_kind("https://drive.google.com/file/d/abc123/view") == ("file", "abc123")
|
||||
assert drive_kind("https://drive.google.com/drive/folders/xyz789?usp=drive_link") == (
|
||||
"folder", "xyz789"
|
||||
)
|
||||
assert drive_kind("https://example.com") is None
|
||||
12
tests/test_summary_annotation_packet.py
Normal file
12
tests/test_summary_annotation_packet.py
Normal file
@ -0,0 +1,12 @@
|
||||
from scripts.build_summary_annotation_packet import select_group_diverse
|
||||
|
||||
|
||||
def test_select_group_diverse_takes_each_group_before_seconds():
|
||||
rows = [
|
||||
{"source_group": "a", "row_key": "a1"},
|
||||
{"source_group": "a", "row_key": "a2"},
|
||||
{"source_group": "b", "row_key": "b1"},
|
||||
{"source_group": "c", "row_key": "c1"},
|
||||
]
|
||||
selected = select_group_diverse(rows, limit=3, seed=1)
|
||||
assert {row["source_group"] for row in selected} == {"a", "b", "c"}
|
||||
Loading…
Reference in New Issue
Block a user