"""배치 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())