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:
parent
76c710c6ef
commit
f4fab1089e
Binary file not shown.
@ -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())
|
|
||||||
@ -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()
|
||||||
]
|
]
|
||||||
|
if not args.no_excerpt_sheet:
|
||||||
build_excerpt_sheet(wb, excerpts, records)
|
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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user