상무님 지적 두 가지를 검증 가능한 형태로 만든다. 1) 중복 원고: 코퍼스 6343건 중 247그룹 503건이 중복이고, 침해의심 103건 중 28건이 여기서 비롯됐다. 다만 247그룹 중 234그룹이 서로 다른 문서 사이의 중복이라 같은 제출자의 재제출인지 다른 제출자의 표절인지 아직 모른다. 지우기 전에 원문째로 남기도록 build_duplicate_report.py 를 둔다. 2) 연속 일치 기준 80자: 근거 없이 잡힌 초기값이다. 무관한 원고 4만 쌍을 대조해 재보니 3어절 100%, 4어절 99.8%, 5어절 47%, 7어절 0% 오탐이다 (한 건을 6343건과 대조하는 효과 반영). 관행인 3~5어절은 쓸 수 없고 7어절 = 공백 포함 27자가 오탐 0% 를 유지하는 최저점이다. 배치에 --exclude-segments 와 --min-exact-span 을 추가한다. 중복은 질의와 색인 양쪽에서 함께 빼야 한다. 한쪽만 빼면 지운 원고가 여전히 상대로 잡힌다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
176 lines
7.7 KiB
Python
176 lines
7.7 KiB
Python
"""코퍼스 안의 중복 원고를 찾아 목록과 제외 대상 세그먼트를 만든다.
|
|
|
|
중복 제거 후 재실행하면 중복에서 비롯된 침해의심 건이 결과에서 사라진다.
|
|
사라지기 전에 그 건들이 어떤 문서 사이의 중복이었는지 남겨두어야, 나중에
|
|
같은 제출자의 중복인지 다른 제출자의 표절인지 확인할 수 있다.
|
|
|
|
산출물
|
|
- 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())
|