diff --git a/reports/침해판별_결과보고_20260902.xlsx b/reports/침해판별_결과보고_20260902.xlsx index 9df3099..002b786 100644 Binary files a/reports/침해판별_결과보고_20260902.xlsx and b/reports/침해판별_결과보고_20260902.xlsx differ diff --git a/scripts/build_duplicate_report.py b/scripts/build_duplicate_report.py deleted file mode 100644 index becd78d..0000000 --- a/scripts/build_duplicate_report.py +++ /dev/null @@ -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()) diff --git a/scripts/build_report_xlsx.py b/scripts/build_report_xlsx.py index f29e080..8d486dc 100644 --- a/scripts/build_report_xlsx.py +++ b/scripts/build_report_xlsx.py @@ -6,7 +6,9 @@ from __future__ import annotations import argparse +import hashlib import json +import re from collections import Counter 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 + +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 SPAN_MIN = 80 COVERAGE_MIN = 0.30 @@ -437,6 +527,8 @@ def main() -> int: parser.add_argument("--compare-jsonl", type=Path, help="비교 시트를 붙일 이전 회차 JSONL") parser.add_argument("--backend", default="", help="검색 백엔드 표기 (미지정 시 JSONL 값 사용)") 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, help="원문 대조 시트를 붙일 발췌 JSONL (extract_suspected_excerpts.py 산출물)") args = parser.parse_args() @@ -456,7 +548,9 @@ def main() -> int: for line in args.excerpts_jsonl.read_text(encoding="utf-8").splitlines() 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_summary_sheet(wb, rows, backend=backend, notes=args.note) if args.compare_jsonl: