배치 산출 JSONL은 엔진 내부 값(retrieval_backend, legal_judgment_method, case_id 등 전 건 단일값 컬럼 포함)까지 22컬럼을 담고 있어 그대로 공유하면 오독을 부른다. 원본 JSONL을 건드리지 않고 보고에 필요한 컬럼만 추려 재생성하는 별도 스크립트를 추가한다. - 침해 판별 결과: 전수 8컬럼, 의심 건 우선 정렬 - 의심 건 상세: 침해유형 한글화 + 관련 판례 연결 - 요약 통계: 근거 스팬 보유 건수와 해석 유의사항 - 1차_vs_2차 비교: 근거 스팬/커버리지 병기, 상위 N 절단 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""배치 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}"
|
|
|
|
|
|
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 (
|
|
("판정 방식", "규칙 기반 (판례 매칭, 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
|
|
|
|
|
|
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="요약 시트에 남길 유의사항")
|
|
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)
|
|
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())
|