o2o-plagiarism-ai/scripts/build_report_xlsx.py
hbyang f4fab1089e feat: fold duplicate analysis into the report workbook
별도 파일을 늘리지 않고 기존 결과보고 워크북에 '중복 원고' 시트로 넣는다.
중복 제거 후 재실행하면 28건이 결과에서 사라지므로, 사라지기 전에 어떤
문서 사이의 중복이었는지 원문째로 남겨야 나중에 같은 제출자의 재제출인지
다른 제출자의 표절인지 확인할 수 있다.

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

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

566 lines
26 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 hashlib
import json
import re
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
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
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("--no-excerpt-sheet", action="store_true",
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()
]
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:
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())