feat: fold duplicate analysis into the report workbook

별도 파일을 늘리지 않고 기존 결과보고 워크북에 '중복 원고' 시트로 넣는다.
중복 제거 후 재실행하면 28건이 결과에서 사라지므로, 사라지기 전에 어떤
문서 사이의 중복이었는지 원문째로 남겨야 나중에 같은 제출자의 재제출인지
다른 제출자의 표절인지 확인할 수 있다.

--no-excerpt-sheet 로 원문 대조 시트 없이 중복 시트만 붙일 수 있게 했다.
결과보고는 원문 없는 배포판이라 이 경로를 쓴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-03 10:44:18 +09:00
parent 76c710c6ef
commit f4fab1089e
3 changed files with 95 additions and 176 deletions

View File

@ -1,175 +0,0 @@
"""코퍼스 안의 중복 원고를 찾아 목록과 제외 대상 세그먼트를 만든다.
중복 제거 재실행하면 중복에서 비롯된 침해의심 건이 결과에서 사라진다.
사라지기 전에 건들이 어떤 문서 사이의 중복이었는지 남겨두어야, 나중에
같은 제출자의 중복인지 다른 제출자의 표절인지 확인할 있다.
산출물
- XLSX : 중복 그룹 목록과, 중복 때문에 침해의심이 건의 원문
- TXT : 재실행 제외할 segment_id 목록 (그룹마다 건만 남긴다)
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from collections import defaultdict
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
HEADER_FILL = PatternFill("solid", fgColor="D9E1F2")
NOTE_FONT = Font(italic=True, color="7F7F7F")
def normalize(text: str) -> str:
"""공백 차이만 있는 원고를 같은 것으로 본다."""
return re.sub(r"\s+", "", text or "")
def text_key(text: str) -> str:
return hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()[:16]
def load_jsonl(path: Path) -> dict[str, dict]:
rows = {}
for line in path.read_text(encoding="utf-8").splitlines():
if line.strip():
row = json.loads(line)
rows[row["query_key"]] = row
return rows
def write_header(sheet, columns) -> None:
sheet.append([label for label, _ in columns])
for index, (_, width) in enumerate(columns, start=1):
cell = sheet.cell(row=1, column=index)
cell.font = Font(bold=True)
cell.fill = HEADER_FILL
sheet.column_dimensions[get_column_letter(index)].width = width
sheet.freeze_panes = "A2"
GROUP_COLUMNS = [
("중복 그룹", 10), ("중복 건수", 10), ("문서 수", 9), ("성격", 22),
("가명 제목들", 40), ("문서 ID들", 60), ("본문 길이", 10), ("본문", 80),
]
SUSPECT_COLUMNS = [
("가명 제목", 20), ("문서 ID", 28), ("매칭 상대 제목", 20), ("매칭 상대 문서 ID", 28),
("결합유사도", 11), ("본문 완전 동일", 13), ("본문 길이", 10),
("검사 대상 원문", 70), ("매칭 상대 원문", 70),
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--excerpts-jsonl", type=Path, required=True)
parser.add_argument("--batch-jsonl", type=Path, required=True)
parser.add_argument("--out-xlsx", type=Path, required=True)
parser.add_argument("--out-exclude", type=Path, required=True,
help="재실행 시 제외할 segment_id 목록 (한 줄에 하나)")
args = parser.parse_args()
excerpts = load_jsonl(args.excerpts_jsonl)
batch = load_jsonl(args.batch_jsonl)
groups: dict[str, list[str]] = defaultdict(list)
for key, row in excerpts.items():
if normalize(row["검사 대상 원문"]):
groups[text_key(row["검사 대상 원문"])].append(key)
duplicates = {key: keys for key, keys in groups.items() if len(keys) > 1}
# 그룹마다 첫 건만 남기고 나머지를 제외 대상으로 삼는다.
excluded = [key for keys in duplicates.values() for key in sorted(keys)[1:]]
segment_ids = [batch[key].get("query_segment_id") or batch[key]["doc_id"] for key in excluded]
args.out_exclude.parent.mkdir(parents=True, exist_ok=True)
args.out_exclude.write_text("\n".join(segment_ids) + "\n", encoding="utf-8")
wb = Workbook()
wb.remove(wb.active)
# 1) 중복 때문에 침해의심이 된 건 — 사라지기 전에 원문째로 남긴다.
sheet = wb.create_sheet("중복 기인 의심 건")
write_header(sheet, SUSPECT_COLUMNS)
affected = [
key for key, row in excerpts.items()
if row["침해 여부"] == "침해 의심" and len(groups[text_key(row["검사 대상 원문"])]) > 1
]
for key in sorted(affected, key=lambda k: -(excerpts[k]["결합유사도"] or 0)):
row, meta = excerpts[key], batch[key]
identical = normalize(row["검사 대상 원문"]) == normalize(row["매칭 상대 원문"])
sheet.append([
row["가명 제목"], meta["doc_id"], row.get("매칭 상대 제목") or "-",
meta.get("매칭 상대 doc") or "-", row["결합유사도"],
"" if identical else "아니오", len(normalize(row["검사 대상 원문"])),
row["검사 대상 원문"], row["매칭 상대 원문"],
])
for column in (8, 9):
sheet.cell(row=sheet.max_row, column=column).alignment = Alignment(
wrap_text=True, vertical="top")
sheet.cell(row=sheet.max_row, column=5).number_format = "0.0000"
sheet.auto_filter.ref = f"A1:{get_column_letter(len(SUSPECT_COLUMNS))}{sheet.max_row}"
# 2) 중복 그룹 전체 목록
sheet = wb.create_sheet("중복 그룹 전체")
write_header(sheet, GROUP_COLUMNS)
for index, (_, keys) in enumerate(
sorted(duplicates.items(), key=lambda item: (-len(item[1]), item[0])), start=1
):
docs = sorted({batch[key]["doc_id"] for key in keys})
titles = sorted({excerpts[key]["가명 제목"] for key in keys})
body = excerpts[keys[0]]["검사 대상 원문"]
sheet.append([
index, len(keys), len(docs),
"서로 다른 문서 사이" if len(docs) > 1 else "같은 문서 안 (적재 오류)",
", ".join(titles), ", ".join(docs), len(normalize(body)), body,
])
sheet.cell(row=sheet.max_row, column=8).alignment = Alignment(
wrap_text=True, vertical="top")
sheet.auto_filter.ref = f"A1:{get_column_letter(len(GROUP_COLUMNS))}{sheet.max_row}"
# 3) 요약
sheet = wb.create_sheet("요약")
cross = sum(1 for keys in duplicates.values()
if len({batch[key]["doc_id"] for key in keys}) > 1)
for label, value in (
("검사 대상 전체", len(excerpts)),
("고유 본문", len(groups)),
("중복 그룹", len(duplicates)),
(" 서로 다른 문서 사이", cross),
(" 같은 문서 안 (적재 오류)", len(duplicates) - cross),
("중복에 속한 건", sum(len(keys) for keys in duplicates.values())),
("재실행 시 제외할 건", len(excluded)),
("제외 후 검사 대상", len(excerpts) - len(excluded)),
("", ""),
("침해의심 전체", sum(1 for r in excerpts.values() if r["침해 여부"] == "침해 의심")),
(" 중복에서 비롯된 건", len(affected)),
(" 중복과 무관한 건",
sum(1 for r in excerpts.values() if r["침해 여부"] == "침해 의심") - len(affected)),
):
sheet.append([label, value])
sheet.append([])
for line in (
"중복 제거는 원본 코퍼스를 지우는 것이 아니라, 재실행 시 해당 세그먼트를 검사 대상에서 빼는 것입니다.",
"'서로 다른 문서 사이'의 중복은 같은 제출자가 두 번 낸 것일 수도, 다른 제출자의 표절일 수도 있습니다.",
"후자라면 그것이 곧 침해 사례이므로, 이 목록의 문서 출처를 확인한 뒤 판단해야 합니다.",
):
sheet.append([line])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
sheet.column_dimensions["A"].width = 30
sheet.column_dimensions["B"].width = 14
args.out_xlsx.parent.mkdir(parents=True, exist_ok=True)
wb.save(args.out_xlsx)
print(f"wrote {args.out_xlsx}")
print(f"wrote {args.out_exclude} ({len(segment_ids)}건 제외 대상)")
print(f"중복 그룹 {len(duplicates)} / 중복 기인 의심 건 {len(affected)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -6,7 +6,9 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import json import json
import re
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
@ -299,6 +301,94 @@ def build_criteria_sheet(wb: Workbook, rows: list[dict], excerpts: list[dict] |
sheet.column_dimensions[get_column_letter(index)].width = width sheet.column_dimensions[get_column_letter(index)].width = width
def _norm(text: str) -> str:
"""공백 차이만 있는 원고를 같은 것으로 본다."""
return re.sub(r"\s+", "", text or "")
def _text_key(text: str) -> str:
return hashlib.sha256(_norm(text).encode("utf-8")).hexdigest()[:16]
def duplicate_groups(excerpts: list[dict]) -> dict[str, list[dict]]:
groups: dict[str, list[dict]] = {}
for row in excerpts:
if _norm(row.get("검사 대상 원문", "")):
groups.setdefault(_text_key(row["검사 대상 원문"]), []).append(row)
return {key: rows for key, rows in groups.items() if len(rows) > 1}
DUP_COLUMNS = [
("가명 제목", 20), ("문서 ID", 28), ("매칭 상대 제목", 20), ("매칭 상대 문서 ID", 28),
("결합유사도", 11), ("본문 완전 동일", 13), ("본문 길이", 10),
("같은 본문을 가진 문서", 46), ("검사 대상 원문", 70), ("매칭 상대 원문", 70),
]
def build_duplicate_sheet(wb: Workbook, excerpts: list[dict], records: dict[str, dict]) -> None:
"""중복 제거 후 재실행하면 사라질 건들을 원문째로 남긴다."""
sheet = wb.create_sheet("중복 원고")
groups = duplicate_groups(excerpts)
member_of = {r["query_key"]: rows for rows in groups.values() for r in rows}
affected = [
r for r in excerpts
if r.get("침해 여부") == "침해 의심" and r["query_key"] in member_of
]
cross = sum(1 for rows in groups.values()
if len({records[r["query_key"]]["doc_id"] for r in rows}) > 1)
suspected = sum(1 for r in excerpts if r.get("침해 여부") == "침해 의심")
sheet.append(["중복 원고 현황"])
sheet.cell(row=1, column=1).font = Font(bold=True, size=12)
for label, value, note in (
("검사 대상 전체", len(excerpts), ""),
("고유 본문", len(excerpts) - sum(len(v) - 1 for v in groups.values()), ""),
("중복 그룹", len(groups), ""),
(" 서로 다른 문서 사이", cross, "같은 제출자의 재제출인지 다른 제출자의 표절인지 확인 필요"),
(" 같은 문서 안", len(groups) - cross, "적재 오류로 보임"),
("중복에 속한 건", sum(len(v) for v in groups.values()), ""),
("침해의심 전체", suspected, ""),
(" 중복에서 비롯된 건", len(affected), "중복 제거 후 재실행하면 사라짐"),
(" 중복과 무관한 건", suspected - len(affected), "중복을 제거해도 남음"),
):
sheet.append([label, value, note])
sheet.append([])
sheet.append([f"중복에서 비롯된 침해의심 {len(affected)}건 — 사라지기 전 원문 보존"])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
header_row = sheet.max_row + 1
sheet.append([label for label, _ in DUP_COLUMNS])
for index, (_, width) in enumerate(DUP_COLUMNS, start=1):
cell = sheet.cell(row=header_row, column=index)
cell.font = Font(bold=True)
cell.fill = HEADER_FILL
sheet.column_dimensions[get_column_letter(index)].width = width
for row in sorted(affected, key=lambda r: -(r.get("결합유사도") or 0)):
meta = records[row["query_key"]]
peers = sorted({records[r["query_key"]]["doc_id"] for r in member_of[row["query_key"]]})
sheet.append([
row["가명 제목"], meta["doc_id"], row.get("매칭 상대 제목") or "-",
meta.get("매칭 상대 doc") or "-", row.get("결합유사도"),
"" if _norm(row["검사 대상 원문"]) == _norm(row.get("매칭 상대 원문", "")) else "아니오",
len(_norm(row["검사 대상 원문"])), ", ".join(peers),
row["검사 대상 원문"], row.get("매칭 상대 원문") or "-",
])
for column in (8, 9, 10):
sheet.cell(row=sheet.max_row, column=column).alignment = Alignment(
wrap_text=True, vertical="top")
sheet.cell(row=sheet.max_row, column=5).number_format = "0.0000"
sheet.append([])
for line in (
"중복 제거는 원본 코퍼스를 지우는 것이 아니라, 재실행 시 해당 세그먼트를 검사 대상에서 빼는 작업입니다.",
"'서로 다른 문서 사이'의 중복은 같은 제출자가 두 번 낸 것일 수도, 다른 제출자의 표절일 수도 있습니다.",
"후자라면 그 자체가 침해 사례이므로, 위 목록의 문서 출처를 확인한 뒤 판단해야 합니다.",
):
sheet.append([line])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
SCORE_MIN = 0.65 SCORE_MIN = 0.65
SPAN_MIN = 80 SPAN_MIN = 80
COVERAGE_MIN = 0.30 COVERAGE_MIN = 0.30
@ -437,6 +527,8 @@ def main() -> int:
parser.add_argument("--compare-jsonl", type=Path, help="비교 시트를 붙일 이전 회차 JSONL") parser.add_argument("--compare-jsonl", type=Path, help="비교 시트를 붙일 이전 회차 JSONL")
parser.add_argument("--backend", default="", help="검색 백엔드 표기 (미지정 시 JSONL 값 사용)") parser.add_argument("--backend", default="", help="검색 백엔드 표기 (미지정 시 JSONL 값 사용)")
parser.add_argument("--note", action="append", default=[], help="요약 시트에 남길 유의사항") parser.add_argument("--note", action="append", default=[], help="요약 시트에 남길 유의사항")
parser.add_argument("--no-excerpt-sheet", action="store_true",
help="원문 대조 시트는 만들지 않고 중복 원고 시트만 붙인다")
parser.add_argument("--excerpts-jsonl", type=Path, parser.add_argument("--excerpts-jsonl", type=Path,
help="원문 대조 시트를 붙일 발췌 JSONL (extract_suspected_excerpts.py 산출물)") help="원문 대조 시트를 붙일 발췌 JSONL (extract_suspected_excerpts.py 산출물)")
args = parser.parse_args() args = parser.parse_args()
@ -456,7 +548,9 @@ def main() -> int:
for line in args.excerpts_jsonl.read_text(encoding="utf-8").splitlines() for line in args.excerpts_jsonl.read_text(encoding="utf-8").splitlines()
if line.strip() if line.strip()
] ]
build_excerpt_sheet(wb, excerpts, records) if not args.no_excerpt_sheet:
build_excerpt_sheet(wb, excerpts, records)
build_duplicate_sheet(wb, excerpts, records)
build_criteria_sheet(wb, rows, excerpts if args.excerpts_jsonl else None) build_criteria_sheet(wb, rows, excerpts if args.excerpts_jsonl else None)
build_summary_sheet(wb, rows, backend=backend, notes=args.note) build_summary_sheet(wb, rows, backend=backend, notes=args.note)
if args.compare_jsonl: if args.compare_jsonl: