o2o-plagiarism-ai/scripts/build_report_xlsx.py
hbyang e89cd23ed6 feat: report original text and judgment criteria for review
검토자가 침해 여부를 눈으로 판별하려면 원문이 있어야 하고, 어떤 조건으로
걸렸는지 알아야 한다. 두 가지를 워크북에 싣는다.

- extract_suspected_excerpts.py: 코퍼스 DB(읽기 전용)에서 질의·상대 원문과
  일치 구간을 뽑는다. 킹서버 기본 python 이 3.6 이라 이 파일만 구문을 맞췄다.
- '판정 기준' 시트: 3개 OR 조건과 결합유사도 가중치, 조건별 충족 현황
- '원문 대조' 시트: 판정 사유 컬럼으로 각 건이 걸린 조건을 표시

판례 표기도 바로잡는다. USE_LLM_LEGAL_JUDGE=false 는 LLM 법적 판단만
끈 것이고 판례 검색은 규칙 기반으로 늘 동작한다. legal_risk.assess() 가
점수 하한 없이 상위 5건을 붙이므로 매칭 미탐지 건에도 판례가 채워진다.
한 줄에 뭉쳐 두면 모순으로 읽혀 판례 검색과 법적 판단을 분리해 적었다.

원문이 담긴 산출물은 gitignore 로 제외한다. 필요하면 위 스크립트로
코퍼스에서 다시 뽑을 수 있어 저장소에 영구 보존할 이유가 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:49:50 +09:00

472 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""배치 JSONL에서 보고용 XLSX를 다시 만든다.
배치 산출 JSONL은 엔진 내부 값까지 전부 담고 있어 그대로 공유하면 오독을 부른다.
이 스크립트는 원본 JSONL을 건드리지 않고, 보고에 필요한 컬럼만 추려 다시 쓴다.
"""
from __future__ import annotations
import argparse
import json
from collections import Counter
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
TAG_LABELS = {
"reproduction": "복제",
"citation_missing": "출처표시 누락",
"public_transmission": "공중송신",
"adaptation": "2차적저작물작성",
"distribution": "배포",
"attribution": "성명표시",
}
RESULT_COLUMNS = [
("doc_id", "doc_id", 28),
("가명 제목", "가명 제목", 22),
("원천 구분", "원천 구분", 14),
("침해 여부", "침해 여부", 12),
("결합유사도", "결합유사도", 11),
("매칭 상대 제목", "매칭 상대 제목", 22),
("evidence 스팬 수", "근거 스팬 수", 12),
("union_coverage", "근거 커버리지", 12),
]
DETAIL_COLUMNS = [
("doc_id", "doc_id", 28),
("가명 제목", "가명 제목", 22),
("원천 구분", "원천 구분", 14),
("결합유사도", "결합유사도", 11),
("매칭 상대 제목", "매칭 상대 제목", 22),
("매칭 상대 doc", "매칭 상대 doc", 28),
("evidence 스팬 수", "근거 스팬 수", 12),
("union_coverage", "근거 커버리지", 12),
("text 점수", "표현 유사도", 11),
("lemma 점수", "어휘 유사도", 11),
("주 법령태그", "주 침해유형", 18),
("보조 법령태그", "보조 침해유형", 16),
("관련 판례 사건번호", "관련 판례 사건번호 (관련성 미검증)", 46),
("judgment_summary", "판단 요약", 70),
]
HEADER_FILL = PatternFill("solid", fgColor="D9E1F2")
NOTE_FONT = Font(italic=True, color="7F7F7F")
def load(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 localize_tags(value) -> str:
tags = [tag.strip() for tag in str(value or "").split(",") if tag.strip()]
return ", ".join(TAG_LABELS.get(tag, tag) for tag in tags)
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
cell.alignment = Alignment(vertical="center")
sheet.column_dimensions[get_column_letter(index)].width = width
sheet.freeze_panes = "A2"
def cell_value(row: dict, key: str):
value = row.get(key)
if key in ("주 법령태그", "보조 법령태그"):
return localize_tags(value)
if key == "매칭 상대 제목" and not value:
return "-"
return value
RATIO_COLUMNS = {"결합유사도", "근거 커버리지", "표현 유사도", "어휘 유사도"}
def append_rows(sheet, rows, columns) -> None:
for row in rows:
sheet.append([cell_value(row, key) for key, _, _ in columns])
for index, (_, label, _) in enumerate(columns, start=1):
if label not in RATIO_COLUMNS:
continue
for cell in sheet.iter_cols(min_col=index, max_col=index, min_row=2):
for item in cell:
item.number_format = "0.0000"
def build_result_sheet(wb: Workbook, rows: list[dict]) -> None:
sheet = wb.create_sheet("침해 판별 결과")
write_header(sheet, RESULT_COLUMNS)
order = {"침해 의심": 0, "매칭 미탐지": 1}
ordered = sorted(rows, key=lambda row: (order.get(row["침해 여부"], 9), -(row["결합유사도"] or 0)))
append_rows(sheet, ordered, RESULT_COLUMNS)
sheet.auto_filter.ref = f"A1:{get_column_letter(len(RESULT_COLUMNS))}{sheet.max_row}"
def build_detail_sheet(wb: Workbook, rows: list[dict]) -> None:
sheet = wb.create_sheet("의심 건 상세")
write_header(sheet, DETAIL_COLUMNS)
suspected = sorted(
(row for row in rows if row["침해 의심"]),
key=lambda row: -(row["결합유사도"] or 0),
)
append_rows(sheet, suspected, DETAIL_COLUMNS)
sheet.auto_filter.ref = f"A1:{get_column_letter(len(DETAIL_COLUMNS))}{sheet.max_row}"
sheet.append([])
for line in (
"관련 판례는 침해 판정의 근거가 아닙니다. 엔진이 법령태그와 어휘 유사도로 뽑은 상위 5건이며, "
"점수 하한이 없어 관련성이 낮은 판례도 항상 채워집니다.",
"실제로 매칭 미탐지 건에도 같은 방식으로 판례가 붙습니다. 판례가 있다는 사실만으로 "
"침해 가능성을 판단하시면 안 되며, 사건번호를 직접 확인하셔야 합니다.",
):
sheet.append([line])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
def build_summary_sheet(wb: Workbook, rows: list[dict], *, backend: str, notes: list[str]) -> None:
sheet = wb.create_sheet("요약 통계")
sheet.append(["구분", "총 건수", "침해 의심", "의심 비율", "근거 스팬 보유"])
for index in range(1, 6):
sheet.cell(row=1, column=index).font = Font(bold=True)
sheet.cell(row=1, column=index).fill = HEADER_FILL
groups = {
"전체": rows,
"자서전 에피소드": [row for row in rows if row["원천 구분"] == "자서전 에피소드"],
"생활수기": [row for row in rows if row["원천 구분"] == "생활수기"],
}
for label, group in groups.items():
total = len(group)
suspected = [row for row in group if row["침해 의심"]]
with_evidence = sum(1 for row in suspected if (row.get("evidence 스팬 수") or 0) >= 1)
sheet.append([label, total, len(suspected), len(suspected) / total if total else 0, with_evidence])
sheet.cell(row=sheet.max_row, column=4).number_format = "0.00%"
sheet.append([])
sheet.append(["실행 조건", ""])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
sheet.cell(row=sheet.max_row, column=2).font = Font(bold=True)
for label, value in (
("침해 판별", "규칙 기반 (유사도·연속일치·커버리지 3개 조건)"),
("판례 검색", "규칙 기반. 법령태그·어휘 유사도로 상위 5건을 뽑으며 점수 하한이 없음"),
("법적 판단", "미수행 (LLM 법률판단 비활성 — 원문을 외부로 전송하지 않음)"),
("검색 백엔드", backend),
("원문 텍스트", "'원문 대조' 시트에 수록 (그 밖의 시트에는 가명 식별자만)"),
):
sheet.append([label, value])
sheet.append([])
sheet.append(["해석 시 유의사항"])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
for note in notes:
sheet.append([note])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
for index, width in enumerate((22, 20, 14, 12, 14), start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
CRITERIA_ROWS = [
(1, "결합유사도", "≥ 0.65", "persistent_similarity_threshold",
"네 가지 유사도를 가중 합산한 값이 임계값 이상"),
(2, "최장 연속 일치 길이", "≥ 80자", "persistent_min_exact_span",
"두 글이 끊기지 않고 그대로 이어지는 최장 구간"),
(3, "문서 단위 일치 커버리지", "≥ 0.30 (30%)", "persistent_min_coverage",
"질의 문서 전체에서 일치 구간이 차지하는 비율(중복 제외)"),
]
WEIGHT_ROWS = [
("어휘 유사도 (lemma)", 0.45, "형태소 원형 기준. 어미·조사 변형을 흡수"),
("표현 유사도 (text)", 0.30, "표층 문자열 기준"),
("문자 유사도 (character)", 0.15, "문자 3~4-gram 기준"),
("모티프 유사도 (motif)", 0.10, "사건·소재 요소 일치"),
]
PARAMETER_ROWS = [
("후보 재정렬 대상", "상위 20건", "persistent_rerank_top_k",
"정밀 비교에 참여하는 후보 수"),
("증거 스팬 최소 길이", "12자", "_evidence_spans(min_match)",
"이보다 짧은 일치는 증거로 세지 않음"),
("증거 스팬 표시 개수", "최대 10개", "_evidence_spans(limit)",
"커버리지 계산은 전량, 표시만 제한"),
("자기 문서 제외", "적용", "source_group 필터",
"동일 문서 및 동일 원천 그룹은 후보에서 제외"),
("판례 검색", "상위 5건 고정", "legal_risk.assess()",
"법령태그 0.55 + 어휘 0.20 + 판시기준 0.10 + 유형 0.08 + 충실도 0.02 로 정렬"),
("판례 점수 하한", "없음", "legal_risk.assess()",
"하한이 없어 관련성이 낮아도 항상 5건이 붙는다. 매칭 미탐지 건에도 판례가 채워지는 이유"),
("법적 판단", "미수행", "USE_LLM_LEGAL_JUDGE=false",
"판례를 근거로 침해 여부를 판단하는 단계는 실행하지 않음. 외부 LLM에 원문 미전송"),
]
def build_criteria_sheet(wb: Workbook, rows: list[dict], excerpts: list[dict] | None = None) -> None:
sheet = wb.create_sheet("판정 기준")
def header(*labels: str) -> None:
sheet.append(list(labels))
for index in range(1, len(labels) + 1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = Font(bold=True)
cell.fill = HEADER_FILL
def title(text: str) -> None:
sheet.append([text])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True, size=12)
def note(text: str) -> None:
sheet.append([text])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
title("1. 침해 의심 판정 조건")
note("세 조건 중 하나 이상을 충족하면 침해 의심으로 분류합니다 (OR 조건). 세 조건 모두를 요구하지 않습니다.")
header("번호", "조건", "기준값", "설정 키", "설명")
for row in CRITERIA_ROWS:
sheet.append(list(row))
sheet.append([])
title("2. 결합유사도 산식")
note("결합유사도 = 어휘×0.45 + 표현×0.30 + 문자×0.15 + 모티프×0.10 (실측 기반 가중치)")
header("구성 요소", "가중치", "설명")
for label, weight, description in WEIGHT_ROWS:
sheet.append([label, weight, description])
sheet.cell(row=sheet.max_row, column=2).number_format = "0.00"
sheet.append([])
title("3. 조건별 충족 현황 (침해 의심 건 기준)")
suspected = [row for row in rows if row["침해 의심"]]
longest_by_key = {}
if excerpts:
for item in excerpts:
spans = item.get("일치 구간") or []
longest_by_key[item["query_key"]] = max((len(span) for span in spans), default=0)
# 조건별 충족 수와 조합별 건수는 서로 다른 집계다. 한 카운터에 섞으면
# 단독 사유 건이 양쪽에 잡혀 합계가 전체 건수를 넘는다.
condition_counts = Counter()
combo_counts = Counter()
for row in suspected:
reasons = judgment_reasons(row, longest_by_key.get(row["query_key"], 0))
combo_counts[" + ".join(reasons) if reasons else "(사유 미상)"] += 1
for reason in reasons:
condition_counts[reason] += 1
header("구분", "건수", "비고")
sheet.append(["침해 의심 전체", len(suspected), ""])
note("아래 세 줄은 중복 집계입니다. 한 건이 여러 조건을 동시에 충족할 수 있어 합계가 103을 넘습니다.")
sheet.append(["조건 ① 유사도 (0.65 이상) 충족", condition_counts["①유사도"], ""])
if excerpts:
sheet.append(["조건 ② 연속일치 (80자 이상) 충족", condition_counts["②연속일치"], "원문 대조로 실측"])
sheet.append(["조건 ③ 커버리지 (30% 이상) 충족", condition_counts["③커버리지"], ""])
sheet.append([])
sheet.append(["판정 사유 조합별 건수", "", "'원문 대조' 시트의 판정 사유 컬럼과 같은 값. 합계 = 103"])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
for key, value in sorted(combo_counts.items(), key=lambda item: (-item[1], item[0])):
single = " + " not in key and not key.startswith("(")
sheet.append([
key + (" 단독" if single else ""), value,
"이 조건 하나만으로 걸린 건" if single else "",
])
sheet.append(["합계", sum(combo_counts.values()), ""])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
if not excerpts:
note("조건 ②(최장 연속 일치 80자)는 원문 발췌 파일이 있어야 집계할 수 있습니다. --excerpts-jsonl 을 주면 실측값이 채워집니다.")
sheet.append([])
title("4. 그 밖의 동작 기준")
header("항목", "", "설정 키", "설명")
for row in PARAMETER_ROWS:
sheet.append(list(row))
sheet.append([])
title("5. 기준값 신뢰도에 관한 한계")
for line in (
"결합유사도 임계값 0.65는 실데이터의 오탐 분포를 측정해 도출한 값이 아닌 잠정값입니다 "
"(similarity_threshold_calibrated = false). API 응답에도 provisional = true 로 표기됩니다.",
"따라서 현재 결과로 정확도(정밀도·재현율)를 제시할 수 없습니다. "
"원문 대조로 정답 라벨을 만든 뒤 임계값을 재조정하는 것이 다음 단계입니다.",
"'매칭 미탐지'는 등록 코퍼스 안에서 일치 증거를 찾지 못했다는 뜻이며 비침해 확정이 아닙니다.",
):
note(line)
for index, width in enumerate((26, 24, 18, 30, 62), start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
SCORE_MIN = 0.65
SPAN_MIN = 80
COVERAGE_MIN = 0.30
def judgment_reasons(row: dict, longest_span: int) -> list[str]:
"""어느 조건으로 의심 판정이 났는지. detector.py 의 OR 조건과 같은 기준."""
reasons = []
if (row.get("결합유사도") or 0) >= SCORE_MIN:
reasons.append("①유사도")
if longest_span >= SPAN_MIN:
reasons.append("②연속일치")
if (row.get("union_coverage") or 0) >= COVERAGE_MIN:
reasons.append("③커버리지")
return reasons
EXCERPT_COLUMNS = [
("가명 제목", 20), ("침해 여부", 12), ("판정 사유", 26), ("매칭 상대 제목", 20),
("결합유사도", 11), ("일치 구간 길이", 13),
("일치 구간 (똑같은 문장)", 70), ("검사 대상 원문", 70), ("매칭 상대 원문", 70),
]
def build_excerpt_sheet(wb: Workbook, excerpts: list[dict], records: dict[str, dict]) -> None:
sheet = wb.create_sheet("원문 대조")
sheet.append([label for label, _ in EXCERPT_COLUMNS])
for index, (_, width) in enumerate(EXCERPT_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 = "D2"
legend = (
"판정 사유: ①유사도 = 결합유사도 0.65 이상 / ②연속일치 = 똑같은 글자가 80자 이상 이어짐 "
"/ ③커버리지 = 문서의 30% 이상이 겹침. 하나만 충족해도 침해 의심입니다."
)
order = {"침해 의심": 0, "매칭 미탐지": 1}
ordered = sorted(
excerpts,
key=lambda r: (order.get(r.get("침해 여부"), 9), -(r.get("결합유사도") or 0)),
)
for row in ordered:
spans = row.get("일치 구간") or []
longest = max((len(span) for span in spans), default=0)
record = records.get(row["query_key"], {})
if row.get("침해 여부") == "침해 의심":
reasons = " + ".join(judgment_reasons(record, longest)) or "(사유 미상)"
else:
reasons = "-"
sheet.append([
row.get("가명 제목"), row.get("침해 여부"), reasons,
row.get("매칭 상대 제목") or "-", row.get("결합유사도"), longest,
"\n---\n".join(spans) if spans else "(일치 구간 없음)", row.get("검사 대상 원문"),
row.get("매칭 상대 원문") or "(매칭된 상대 글 없음)",
])
for column in (7, 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(EXCERPT_COLUMNS))}{sheet.max_row}"
sheet.append([])
sheet.append([legend])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
COMPARE_COLUMNS = [
"가명 제목", "원천 구분", "매칭 상대 제목",
"1차 유사도", "2차 유사도", "근거 스팬(1차)", "근거 스팬(2차)",
"근거 커버리지(1차)", "근거 커버리지(2차)",
]
def build_compare_sheet(wb: Workbook, first: dict[str, dict], second: dict[str, dict]) -> None:
sheet = wb.create_sheet("1차_vs_2차 비교")
flagged_first = {key for key, row in first.items() if row["침해 의심"]}
flagged_second = {key for key, row in second.items() if row["침해 의심"]}
shared = sorted(set(first) & set(second))
deltas = [(second[key]["결합유사도"] or 0) - (first[key]["결합유사도"] or 0) for key in shared]
mean_delta = sum(deltas) / len(deltas) if deltas else 0
sheet.append(["구분", "건수"])
for index in (1, 2):
sheet.cell(row=1, column=index).font = Font(bold=True)
sheet.cell(row=1, column=index).fill = HEADER_FILL
for label, value in (
("1차 의심", len(flagged_first)),
("2차 의심", len(flagged_second)),
("양쪽 공통", len(flagged_first & flagged_second)),
("2차 신규", len(flagged_second - flagged_first)),
("1차에서만", len(flagged_first - flagged_second)),
):
sheet.append([label, value])
sheet.append([])
sheet.append(["결합유사도 평균 변화", round(mean_delta, 4)])
sheet.append([
"주의: 2차는 임베딩 교체로 전 건 유사도가 일괄 상승했습니다. "
"임계값이 동일하므로 의심 건수 증가분을 곧바로 탐지력 향상으로 해석하면 안 됩니다."
])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
def section(title: str, keys: list[str], source: dict[str, dict]) -> None:
sheet.append([])
sheet.append([title])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
sheet.append(COMPARE_COLUMNS)
for index in range(1, len(COMPARE_COLUMNS) + 1):
sheet.cell(row=sheet.max_row, column=index).fill = HEADER_FILL
for key in sorted(keys, key=lambda k: -(source[k]["결합유사도"] or 0)):
a, b = first[key], second[key]
sheet.append([
b["가명 제목"], b["원천 구분"], source[key].get("매칭 상대 제목") or "-",
a["결합유사도"], b["결합유사도"],
a.get("evidence 스팬 수"), b.get("evidence 스팬 수"),
a.get("union_coverage"), b.get("union_coverage"),
])
for column in (4, 5, 8, 9):
sheet.cell(row=sheet.max_row, column=column).number_format = "0.0000"
new_keys = sorted(flagged_second - flagged_first)
with_evidence = [key for key in new_keys if (second[key].get("evidence 스팬 수") or 0) >= 1]
without_evidence = [key for key in new_keys if key not in with_evidence]
section(f"2차 신규 — 근거 스팬 있음 ({len(with_evidence)}건, 우선 검토 대상)", with_evidence, second)
section(f"2차 신규 — 근거 스팬 없음 ({len(without_evidence)}건, 임계값 근접 건)", without_evidence, second)
section(f"1차에서만 검출 ({len(flagged_first - flagged_second)}건)", sorted(flagged_first - flagged_second), first)
for index, width in enumerate((24, 14, 24, 11, 11, 13, 13, 16, 16), start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--jsonl", type=Path, required=True, help="보고 대상 배치 JSONL")
parser.add_argument("--out-xlsx", type=Path, required=True)
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("--excerpts-jsonl", type=Path,
help="원문 대조 시트를 붙일 발췌 JSONL (extract_suspected_excerpts.py 산출물)")
args = parser.parse_args()
records = load(args.jsonl)
rows = list(records.values())
backend = args.backend or str(rows[0].get("retrieval_backend") or "미기재")
wb = Workbook()
wb.remove(wb.active)
build_result_sheet(wb, rows)
build_detail_sheet(wb, rows)
excerpts = None
if args.excerpts_jsonl:
excerpts = [
json.loads(line)
for line in args.excerpts_jsonl.read_text(encoding="utf-8").splitlines()
if line.strip()
]
build_excerpt_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:
build_compare_sheet(wb, load(args.compare_jsonl), records)
args.out_xlsx.parent.mkdir(parents=True, exist_ok=True)
wb.save(args.out_xlsx)
print(f"wrote {args.out_xlsx} sheets={wb.sheetnames}")
return 0
if __name__ == "__main__":
raise SystemExit(main())