#!/usr/bin/env python3 """판례 리스트업 → 배포용 Excel(.xlsx) 생성. 왜 별도로 만드는가: `precedent_listup.csv` 는 UTF-8 BOM 이라 Excel 에서 열리기는 하지만, 받는 쪽이 바로 읽을 수 있는 형태는 아니다. 열 너비·줄바꿈·필터가 없어 판시 요약처럼 긴 칸이 한 줄로 뭉개지고, 등급별로 훑어보기도 어렵다. 이 스크립트는 같은 데이터를 검토자가 그대로 열어 쓸 수 있는 통합문서로 만든다. 원본(source of truth): - 표 데이터: `data/precedents/precedent_listup.csv` - 서술 내용: `docs/PRECEDENT_LISTUP.md` — 아래 상수는 그 문서에서 옮긴 것이며, 문서가 바뀌면 여기도 함께 고쳐야 한다. 수치를 이 파일에서 새로 만들지 말 것. 사용: python scripts/build_precedent_listup_xlsx.py python scripts/build_precedent_listup_xlsx.py --out docs/판례_리스트업.xlsx """ from __future__ import annotations import argparse import csv import io import json import sys from collections import Counter from pathlib import Path from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter ROOT = Path(__file__).resolve().parent.parent # --------------------------------------------------------------------------- # 서식 — 화면과 인쇄 모두에서 읽히도록 최소한으로만 쓴다 # --------------------------------------------------------------------------- INK = "1C1A19" HEAD_FILL = PatternFill("solid", fgColor="2F3B45") HEAD_FONT = Font(name="맑은 고딕", size=10, bold=True, color="FFFFFF") BODY_FONT = Font(name="맑은 고딕", size=10, color=INK) TITLE_FONT = Font(name="맑은 고딕", size=13, bold=True, color="B03A22") SECTION_FONT = Font(name="맑은 고딕", size=11, bold=True, color=INK) NOTE_FONT = Font(name="맑은 고딕", size=9, color="6E6660") LINK_FONT = Font(name="맑은 고딕", size=9, color="1B5E8A", underline="single") GRADE_FILL = { "A": PatternFill("solid", fgColor="E4F0E8"), "B": PatternFill("solid", fgColor="FBF1DE"), "C": PatternFill("solid", fgColor="EFEDEB"), } HAIRLINE = Side(style="thin", color="D8D3CE") CELL_BORDER = Border(left=HAIRLINE, right=HAIRLINE, top=HAIRLINE, bottom=HAIRLINE) TOP_WRAP = Alignment(vertical="top", wrap_text=True) TOP_LEFT = Alignment(vertical="top", horizontal="left") CENTER = Alignment(vertical="center", horizontal="center") # 표 열 정의: (CSV 필드, 표시 이름, 너비, 줄바꿈 여부) COLUMNS = [ ("grade", "등급", 6, False), ("case_id", "사건번호", 15, False), ("court", "법원", 20, True), ("decided", "선고일", 12, False), ("topic", "주제", 16, True), ("title", "사건명", 34, True), ("issue", "쟁점 요약", 52, True), ("why", "본 과제 적용 지점", 46, True), ("work_types", "저작물 유형", 14, True), ("criteria", "판단 기준", 26, True), ("legal_tags", "법적 태그", 22, True), ("url", "출처", 12, False), ] # --------------------------------------------------------------------------- # 서술 시트 — 원본은 docs/PRECEDENT_LISTUP.md # --------------------------------------------------------------------------- FUNNEL = [ ("한국저작권위원회 공식 판례 목록", 2085, "209페이지 전수"), ("제목 기준 후보", 702, "자서전·출판물 본문/구조/인용 및 부속 자산 관련"), ("사건번호 확인 후 병합", 679, "프로젝트 제공 엑셀 사건번호 142개 역검색 포함"), ("엔진 라벨 부족으로 제외", 134, "저작물 유형·판단 기준·법적 태그가 모두 없는 건"), ("운영 적재본", 545, "data/precedents/precedents.jsonl"), ("어문저작물 + 판단 기준 라벨 보유", 77, "1차 선별 모집단"), ("최종 선정", 57, "판시 본문을 개별 확인해 등급 부여"), ] GRADE_DEF = [ ("A", "직접 적용", "어문저작물의 표절·유사성·인용 판단 기준을 직접 제시한다. 판정 로직과 결과 리포트의 근거로 바로 인용할 수 있다."), ("B", "조건부 적용", "자서전 출판 실무의 주변 쟁점(성명 표시, 대필·공저, 동일성유지, 출판계약, 번역·개정판)을 다룬다. 표절 탐지 자체의 기준은 아니지만 서비스가 다뤄야 할 위험 영역이다."), ("C", "참고", "법리는 유용하나 사안이 어문·출판이 아니거나(음악·미술·게임·영상), 가처분·구법 판단이어서 그대로 인용하면 무리가 있다."), ] KEY_THREE = [ ("2013도8793", "대법원", "저서 각 장의 11~40%를 인용하고 각주·참고문헌을 달았어도 정당한 인용이 아니라고 본 사례. 출처를 표시했다는 이유만으로 면책되지 않는다는 정량 기준을 제시한다."), ("2014다14375", "대법원 (석굴암)", "역사적 사실과 그에 대한 새로운 해석은 아이디어 영역이라는 판시. 실재 인물·사건을 공유하는 자서전 간 오탐을 걸러내는 근거다."), ("2018도144", "대법원 (표지갈이)", "저작자 아닌 자를 저작자로 표시해 공표하면 당사자 전원이 동의했더라도 형사처벌 대상이다. 대필 자서전 관행에 직접 걸린다."), ] EXCLUDED_COLLECTION = [ ("공식 상세 페이지 미확인", 103, "프로젝트 제공 엑셀의 사건번호 142개 중 한국저작권위원회 공식 사이트에서 상세 페이지를 찾지 못한 건. 출처를 임의 생성하지 않기 위해 적재에서 제외했다. 전체 목록은 selection_audit.json."), ("엔진 라벨 부족", 134, "저작물 유형·판단 기준·법적 태그가 모두 없어 무차별 매칭을 유발하는 건"), ("제목 후보 탈락", 1380, "공식 목록 2,085건 중 자서전·출판물 범위와 무관한 제목 (약 1,380건)"), ] EXCLUDED_TYPES = [ ("노래방·음원 이용 사건", "2001도4100, 2011다101148, 2012다109798", "공연권·복제권 사안으로 어문 표절 기준을 주지 않는다"), ("사진·이미지 무단 사용", "2019고정1107, 2020고정1154 등 다수", "동일 이미지 복제 여부가 쟁점이라 유사도 판단 기준이 없다"), ("캐릭터·위조상품 밀수", "2019고단9348, 2019고정667", "상품 유통 사안"), ("웹툰·영상 불법 업로드", "2019가합570806, 2019고단2659", "전송권 침해이며 본문 대비 판단이 없다"), ("폰트·프로그램", "95가합11403, 98나23616, 2020나31370", "저작물 유형이 다르다"), ("조세·행정 사건", "2011구합31796, 2016구합54602", "저작권 수입의 과세 문제이며 침해 판단이 아니다"), ("저작물 표시만 있고 판시 본문 없음", "다수", "제목·선고일만으로 법리를 추론하지 않는다는 원칙에 따름"), ] GAPS = [ ("v1.3 대표판례 29건 중 17건이 운영 적재본에 없음", "2006나16757, 2007가합43936, 2010도4468, 2019가단31377, 96다273, 2023카합21631, " "2021가합588060, 2007가합16095, 2019가단5207564, 2013나2004096, 2012도13718, 2007다354, " "2017도19025, 2008카합968, 2008다53812, 2016고정432, 2006가합8583", "대부분 '공식 상세 페이지 미확인 103건'에 걸린 사건. 분류체계의 근거로는 쓰이지만 엔진이 인용할 출처가 확보되지 않았다."), ("v1.3 주제 분류와 실제 판시가 어긋나는 건", "2011다101148, 2012다109798 (노래방 음악저작물) · 2012다73493 (깃털 공예품) · 2017다212095 (게임)", "v1.3에서는 '동일성유지·출판계약' 등으로 묶여 있으나 적재본 판시는 어문 사건이 아니다. 인용 전 원문 대조가 필요하다."), ("선고일 미확인 18건", "2010가합1884, 2014다14375, 2013도8793, 2024노803, 2025가단81306, 2021가합558106, " "2024도19189, 2024나13167, 2022가합516598, 2019가합106853, 2024나2061521, 2024가합96852, " "2024나306378, 2024누94, 2025구합31, 2011도5835, 2012다41410, 77다90", "적재본에 선고일 필드가 없어 판시 본문에서 추출했고, 본문이 요지 한 줄뿐인 건은 채우지 못했다."), ("자서전·회고록·대필 직접 판례 0건", "545건 전체에서 자서전·회고록·대필·평전·구술·유고 키워드 모두 0회", "인접 유형(교재·수험서·논문·소설·드라마) 법리를 유추 적용해야 하며, 그 유추의 타당성이 상담의 핵심 질의가 되어야 한다."), ] QUESTIONS = [ "자서전처럼 실재 사건·인물·연보를 공유하는 저작물 사이에서, 석굴암·선덕여왕 판례의 " "'역사적 사실과 그 해석은 아이디어' 법리는 어디까지 확장되는가.", "2013도8793의 11~40% 인용 비율을 서비스의 경고 임계값으로 참조하는 것이 타당한가. " "비율 외에 '인용 부분을 구별할 수 있는가'라는 질적 기준을 시스템이 어떻게 반영해야 하는가.", "대필·공저 자서전에서 2018도144(표지갈이)의 형사 법리가 실제로 적용될 위험은 어느 정도인가. " "구술자를 저자로 표기하는 관행은 어떻게 평가되는가.", "자서전의 연보·경력·수상 이력 등 사실 나열 구간은 2019가합537427(수험서)의 " "'창작성 없는 개별 부분에는 저작권 효력이 미치지 않는다'는 판시로 처리해도 되는가.", "A등급 목록에서 빠진 쟁점이 있는가. 특히 자서전 특유의 문제(사자의 명예, 유족 동의, " "제3자 사생활 서술)에 관해 실무상 참조되는 판례를 추가로 알고 있는가.", ] DISCLAIMER = ( "본 문서는 법률 의견서가 아니라 데이터 정리 결과입니다. 등급과 쟁점 요약은 적재된 판시 " "본문을 근거로 부여한 것이며, 법적 효력을 갖는 해석이 아닙니다. 각 사건의 판결 원문은 " "대법원 종합법률정보에서 사건번호로 다시 확인해야 합니다." ) # --------------------------------------------------------------------------- # 시트 작성 도우미 # --------------------------------------------------------------------------- def _title_block(ws, title: str, subtitle: str = "", width_cols: int = 4) -> int: """시트 상단 제목. 다음에 쓸 행 번호를 돌려준다.""" ws.cell(row=1, column=1, value=title).font = TITLE_FONT row = 2 if subtitle: cell = ws.cell(row=2, column=1, value=subtitle) cell.font = NOTE_FONT cell.alignment = TOP_WRAP ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=width_cols) row = 3 return row + 1 def _header_row(ws, row: int, labels: list[str]) -> None: for col, label in enumerate(labels, start=1): cell = ws.cell(row=row, column=col, value=label) cell.font = HEAD_FONT cell.fill = HEAD_FILL cell.alignment = CENTER cell.border = CELL_BORDER def _widths(ws, widths: list[int]) -> None: for i, w in enumerate(widths, start=1): ws.column_dimensions[get_column_letter(i)].width = w def _body(cell, *, wrap: bool = True) -> None: cell.font = BODY_FONT cell.alignment = TOP_WRAP if wrap else TOP_LEFT cell.border = CELL_BORDER # --------------------------------------------------------------------------- # 시트 1 — 판례 목록 # --------------------------------------------------------------------------- def sheet_list(wb: Workbook, rows: list[dict]) -> None: ws = wb.create_sheet("판례 목록") counts = Counter(r["grade"] for r in rows) head = _title_block( ws, "자서전·출판물 표절 판단 근거 판례", f"운영 적재본 545건 전수 검토 → 선정 {len(rows)}건 " f"(A {counts['A']} · B {counts['B']} · C {counts['C']}). " "등급 정의와 선정 방법은 '요약·방법' 시트 참조.", width_cols=len(COLUMNS), ) _header_row(ws, head, [label for _, label, _, _ in COLUMNS]) _widths(ws, [w for _, _, w, _ in COLUMNS]) order = {"A": 0, "B": 1, "C": 2} rows = sorted(rows, key=lambda r: (order.get(r["grade"], 9), r["case_id"])) for i, rec in enumerate(rows): r = head + 1 + i for c, (field, _, _, wrap) in enumerate(COLUMNS, start=1): value = rec.get(field, "") cell = ws.cell(row=r, column=c) if field == "url" and value: cell.value = "원문 링크" cell.hyperlink = value cell.font = LINK_FONT cell.alignment = TOP_LEFT cell.border = CELL_BORDER continue cell.value = value _body(cell, wrap=wrap) if field == "grade": cell.alignment = CENTER cell.font = Font(name="맑은 고딕", size=10, bold=True, color=INK) if value in GRADE_FILL: cell.fill = GRADE_FILL[value] last = head + len(rows) ws.auto_filter.ref = f"A{head}:{get_column_letter(len(COLUMNS))}{last}" ws.freeze_panes = ws.cell(row=head + 1, column=3) ws.sheet_view.zoomScale = 90 # --------------------------------------------------------------------------- # 시트 — 적재본 전체 (엔진이 실제로 쓰는 판례) # --------------------------------------------------------------------------- CORPUS_COLUMNS = [ ("_source", "출처", 12, False), ("_grade", "등급", 6, False), ("case_id", "사건번호", 15, False), ("title", "사건명", 44, True), ("_work", "저작물 유형", 14, True), ("_tags", "법적 태그", 24, True), ("_criteria", "판단 기준", 26, True), ("holding_summary", "판시 요약", 74, True), ("source_registered_date", "등록일", 11, False), ("source_url", "출처", 12, False), ] def sheet_corpus(wb: Workbook, corpus: list[dict], rows: list[dict]) -> None: """545건 전수. 어느 것이 컴북스 제공분이고 어느 것이 리스트업에 뽑혔는지 표시한다.""" ws = wb.create_sheet("적재본 전체") grade_of = {r["case_id"]: r["grade"] for r in rows} n_excel = sum(1 for r in corpus if r.get("excel_duplicate")) head = _title_block( ws, f"엔진 적재본 전체 {len(corpus)}건 — 표절 탐지에 실제로 사용되는 판례", f"런타임에 이 {len(corpus)}건을 모두 로드하고, 판정 1건마다 저작물 유형·법적 태그가 " f"맞는 상위 5건을 근거로 인용한다(app/engine/legal_risk.py). " f"출처는 컴북스 제공 {n_excel}건, 한국저작권위원회 공식 목록 {len(corpus) - n_excel}건. " "'등급'이 채워진 행이 리스트업 57건이다.", width_cols=len(CORPUS_COLUMNS), ) _header_row(ws, head, [label for _, label, _, _ in CORPUS_COLUMNS]) _widths(ws, [w for _, _, w, _ in CORPUS_COLUMNS]) order = {"A": 0, "B": 1, "C": 2} corpus = sorted( corpus, key=lambda r: ( 0 if r.get("excel_duplicate") else 1, order.get(grade_of.get(r["case_id"], ""), 9), r["case_id"], ), ) for i, rec in enumerate(corpus): r = head + 1 + i grade = grade_of.get(rec["case_id"], "") derived = { "_source": "컴북스" if rec.get("excel_duplicate") else "위원회", "_grade": grade, "_work": " / ".join(rec.get("work_types") or []), "_tags": " / ".join(rec.get("legal_tags") or []), "_criteria": " / ".join(rec.get("criteria") or []), } for c, (field, _, _, wrap) in enumerate(CORPUS_COLUMNS, start=1): cell = ws.cell(row=r, column=c) if field == "source_url": url = rec.get("source_url") or "" if url: cell.value = "원문 링크" cell.hyperlink = url cell.font = LINK_FONT cell.alignment = TOP_LEFT cell.border = CELL_BORDER continue cell.value = derived.get(field, rec.get(field, "") or "") _body(cell, wrap=wrap) if field == "_grade": cell.alignment = CENTER if grade in GRADE_FILL: cell.fill = GRADE_FILL[grade] cell.font = Font(name="맑은 고딕", size=10, bold=True, color=INK) elif field == "_source": cell.alignment = CENTER if rec.get("excel_duplicate"): cell.font = Font(name="맑은 고딕", size=10, bold=True, color="B03A22") last = head + len(corpus) ws.auto_filter.ref = f"A{head}:{get_column_letter(len(CORPUS_COLUMNS))}{last}" ws.freeze_panes = ws.cell(row=head + 1, column=4) ws.sheet_view.zoomScale = 85 # --------------------------------------------------------------------------- # 시트 — 컴북스 제공분 내역 # --------------------------------------------------------------------------- def sheet_combooks(wb: Workbook, corpus: list[dict], rows: list[dict], audit: dict) -> None: ws = wb.create_sheet("컴북스 제공분") _widths(ws, [40, 10, 74]) grade_of = {r["case_id"]: r["grade"] for r in rows} kept = [r for r in corpus if r.get("excel_duplicate")] picked = [r for r in kept if r["case_id"] in grade_of] missing = audit.get("excel_without_official_detail_case_ids", []) total = audit.get("excel_unique_case_count", 0) r = _title_block( ws, "컴북스 제공 판례의 반영 내역", "회의에서 '우리가 준 판례는 어떻게 됐는가'에 답하기 위한 표다. " "제공받은 사건번호가 모두 적재된 것은 아니며, 빠진 건에는 사유가 있다.", width_cols=3, ) ws.cell(row=r, column=1, value="■ 단계별 반영").font = SECTION_FONT r += 1 _header_row(ws, r, ["단계", "건수", "설명"]) r += 1 funnel = [ ("제공 엑셀 고유 사건번호", total, "중복 제거 기준"), ("공식 상세 페이지 미확인 → 제외", len(missing), "한국저작권위원회 공식 사이트에서 상세 페이지를 찾지 못한 건. 출처를 임의 생성하지 않기 위해 제외했다. 아래 목록 참조."), ("엔진 라벨 부족 → 제외", total - len(missing) - len(kept), "저작물 유형·판단 기준·법적 태그가 모두 없어 무차별 매칭을 유발하는 건"), ("엔진 적재본에 포함", len(kept), "런타임에 실제로 로드된다"), ("리스트업에 선정", len(picked), f"A {sum(1 for x in picked if grade_of[x['case_id']] == 'A')} · " f"B {sum(1 for x in picked if grade_of[x['case_id']] == 'B')} · " f"C {sum(1 for x in picked if grade_of[x['case_id']] == 'C')}"), ] for name, n, note in funnel: ws.cell(row=r, column=1, value=name) ws.cell(row=r, column=2, value=n) ws.cell(row=r, column=3, value=note) for c in (1, 2, 3): _body(ws.cell(row=r, column=c)) ws.cell(row=r, column=2).alignment = CENTER if name.startswith("엔진 적재본"): for c in (1, 2, 3): ws.cell(row=r, column=c).font = Font(name="맑은 고딕", size=10, bold=True, color=INK) r += 1 r += 1 ws.cell(row=r, column=1, value=f"■ 적재된 {len(kept)}건").font = SECTION_FONT r += 1 _header_row(ws, r, ["사건번호", "등급", "사건명"]) r += 1 for rec in sorted(kept, key=lambda x: (grade_of.get(x["case_id"], "Z"), x["case_id"])): g = grade_of.get(rec["case_id"], "") ws.cell(row=r, column=1, value=rec["case_id"]) ws.cell(row=r, column=2, value=g or "—") ws.cell(row=r, column=3, value=rec.get("title", "")) for c in (1, 2, 3): _body(ws.cell(row=r, column=c)) ws.cell(row=r, column=2).alignment = CENTER if g in GRADE_FILL: ws.cell(row=r, column=2).fill = GRADE_FILL[g] r += 1 r += 1 ask = ws.cell( row=r, column=1, value=f"■ 출처 확보가 필요한 {len(missing)}건 — 컴북스 회신 요청 항목", ) ask.font = Font(name="맑은 고딕", size=11, bold=True, color="B03A22") r += 1 note = ws.cell( row=r, column=1, value="사건번호는 받았으나 한국저작권위원회 공식 상세 페이지를 찾지 못해 적재하지 못한 건이다. " "판결문 원문이나 확인 경로를 주시면 그대로 적재할 수 있다. " "사건번호만으로는 엔진이 인용할 출처가 되지 않아 임의로 만들지 않았다.", ) note.font = NOTE_FONT note.alignment = TOP_WRAP ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=3) ws.row_dimensions[r].height = 32 r += 2 per_row = 3 for i in range(0, len(missing), per_row): for j, cid in enumerate(missing[i:i + per_row]): cell = ws.cell(row=r, column=1 + j, value=cid) _body(cell, wrap=False) r += 1 # --------------------------------------------------------------------------- # 시트 2 — 요약·방법 # --------------------------------------------------------------------------- def sheet_summary(wb: Workbook, rows: list[dict]) -> None: ws = wb.create_sheet("요약·방법") _widths(ws, [30, 12, 78]) r = _title_block( ws, "요약과 선정 방법", "선정 기준은 '자서전·출판물의 본문 표절 판단에 실제로 기준을 주는가' 하나다. " "저작권 사건이라는 이유만으로는 넣지 않았다.", width_cols=3, ) ws.cell(row=r, column=1, value="■ 검토 단계별 건수").font = SECTION_FONT r += 1 _header_row(ws, r, ["단계", "건수", "비고"]) r += 1 for name, n, note in FUNNEL: ws.cell(row=r, column=1, value=name) ws.cell(row=r, column=2, value=n) ws.cell(row=r, column=3, value=note) for c in (1, 2, 3): _body(ws.cell(row=r, column=c)) ws.cell(row=r, column=2).alignment = CENTER if name in ("운영 적재본", "최종 선정"): for c in (1, 2, 3): ws.cell(row=r, column=c).font = Font( name="맑은 고딕", size=10, bold=True, color=INK ) r += 1 r += 1 ws.cell(row=r, column=1, value="■ 등급 정의").font = SECTION_FONT r += 1 _header_row(ws, r, ["등급", "구분", "정의"]) r += 1 for grade, label, desc in GRADE_DEF: ws.cell(row=r, column=1, value=grade).fill = GRADE_FILL[grade] ws.cell(row=r, column=2, value=label) ws.cell(row=r, column=3, value=desc) for c in (1, 2, 3): _body(ws.cell(row=r, column=c)) ws.cell(row=r, column=1).alignment = CENTER r += 1 r += 1 ws.cell(row=r, column=1, value="■ 본 과제에 가장 직접적인 3건").font = SECTION_FONT r += 1 _header_row(ws, r, ["사건번호", "법원", "의의"]) r += 1 for case_id, court, why in KEY_THREE: ws.cell(row=r, column=1, value=case_id) ws.cell(row=r, column=2, value=court) ws.cell(row=r, column=3, value=why) for c in (1, 2, 3): _body(ws.cell(row=r, column=c)) r += 1 r += 2 note = ws.cell(row=r, column=1, value=DISCLAIMER) note.font = NOTE_FONT note.alignment = TOP_WRAP ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=3) ws.row_dimensions[r].height = 44 # --------------------------------------------------------------------------- # 시트 3 — 제외 사유 # --------------------------------------------------------------------------- def sheet_excluded(wb: Workbook) -> None: ws = wb.create_sheet("제외 사유") _widths(ws, [30, 34, 76]) r = _title_block( ws, "제외한 판례와 사유", "무리해서 목록을 채우지 않았다. 무엇을 왜 뺐는지 남겨 두어야 이후 상담에서 " "범위를 다시 넓힐지 판단할 수 있다.", width_cols=3, ) ws.cell(row=r, column=1, value="■ 데이터 수집 단계에서 제외").font = SECTION_FONT r += 1 _header_row(ws, r, ["사유", "건수", "설명"]) r += 1 for reason, n, desc in EXCLUDED_COLLECTION: ws.cell(row=r, column=1, value=reason) ws.cell(row=r, column=2, value=n) ws.cell(row=r, column=3, value=desc) for c in (1, 2, 3): _body(ws.cell(row=r, column=c)) ws.cell(row=r, column=2).alignment = CENTER r += 1 r += 1 ws.cell(row=r, column=1, value="■ 적재본에는 있으나 리스트업에서 뺀 유형").font = SECTION_FONT r += 1 _header_row(ws, r, ["유형", "대표 사건번호", "제외 사유"]) r += 1 for kind, cases, reason in EXCLUDED_TYPES: ws.cell(row=r, column=1, value=kind) ws.cell(row=r, column=2, value=cases) ws.cell(row=r, column=3, value=reason) for c in (1, 2, 3): _body(ws.cell(row=r, column=c)) r += 1 # --------------------------------------------------------------------------- # 시트 4 — 데이터 공백 # --------------------------------------------------------------------------- def sheet_gaps(wb: Workbook) -> None: ws = wb.create_sheet("데이터 공백") _widths(ws, [6, 40, 58, 60]) r = _title_block( ws, "확인된 데이터 공백 — 상담 시 짚어야 할 항목", "이 표의 항목은 아직 해소되지 않았다. 전문가 상담 전에 원문 확보가 필요하거나, " "상담 질의로 돌려야 하는 건이다.", width_cols=4, ) _header_row(ws, r, ["#", "항목", "해당 사건", "설명"]) r += 1 for i, (item, cases, desc) in enumerate(GAPS, start=1): ws.cell(row=r, column=1, value=i) ws.cell(row=r, column=2, value=item) ws.cell(row=r, column=3, value=cases) ws.cell(row=r, column=4, value=desc) for c in range(1, 5): _body(ws.cell(row=r, column=c)) ws.cell(row=r, column=1).alignment = CENTER ws.cell(row=r, column=2).font = Font(name="맑은 고딕", size=10, bold=True, color=INK) r += 1 r += 1 warn = ws.cell( row=r, column=2, value="주의: 2012나17150(선덕여왕 항소심)은 1심과 결론이 뒤집힌 사례이며 " "상고심 판단이 적재본에 없다. 항소심 결론을 확정 법리로 인용하지 말 것.", ) warn.font = Font(name="맑은 고딕", size=10, bold=True, color="B03A22") warn.alignment = TOP_WRAP ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=4) ws.row_dimensions[r].height = 32 # --------------------------------------------------------------------------- # 시트 5 — 상담 질의 # --------------------------------------------------------------------------- def sheet_questions(wb: Workbook) -> None: ws = wb.create_sheet("상담 질의") _widths(ws, [6, 104]) r = _title_block( ws, "전문가 상담 시 질의 제안", "자서전 직접 판례가 없다는 점이 확인된 이상, 인접 유형 법리의 유추가 " "타당한지가 상담의 핵심이 되어야 한다.", width_cols=2, ) _header_row(ws, r, ["#", "질의"]) r += 1 for i, q in enumerate(QUESTIONS, start=1): ws.cell(row=r, column=1, value=i) ws.cell(row=r, column=2, value=q) for c in (1, 2): _body(ws.cell(row=r, column=c)) ws.cell(row=r, column=1).alignment = CENTER ws.row_dimensions[r].height = 34 r += 1 # --------------------------------------------------------------------------- def load_corpus(path: Path) -> list[dict]: """엔진이 실제로 로드하는 적재본. 리스트업 57건이 아니라 이쪽이 런타임 후보 풀이다.""" if not path.exists(): raise SystemExit(f"{path} 가 없습니다.") return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] def load_audit(path: Path) -> dict: if not path.exists(): raise SystemExit(f"{path} 가 없습니다.") return json.loads(path.read_text(encoding="utf-8")) def load_rows(path: Path) -> list[dict]: if not path.exists(): raise SystemExit( f"{path} 가 없습니다. 먼저 scripts/build_precedent_listup.py 로 CSV 를 만드세요." ) with io.open(path, encoding="utf-8-sig", newline="") as fh: rows = list(csv.DictReader(fh)) if not rows: raise SystemExit(f"{path} 에 데이터 행이 없습니다.") return rows def main() -> int: ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) ap.add_argument("--csv", type=Path, default=ROOT / "data/precedents/precedent_listup.csv") ap.add_argument("--corpus", type=Path, default=ROOT / "data/precedents/precedents.jsonl", help="엔진이 실제로 로드하는 적재본") ap.add_argument("--audit", type=Path, default=ROOT / "data/precedents/selection_audit.json") ap.add_argument("--out", type=Path, default=ROOT / "docs/판례_리스트업.xlsx") args = ap.parse_args() rows = load_rows(args.csv) corpus = load_corpus(args.corpus) audit = load_audit(args.audit) wb = Workbook() wb.remove(wb.active) # 기본 시트 제거 sheet_list(wb, rows) sheet_corpus(wb, corpus, rows) sheet_combooks(wb, corpus, rows, audit) sheet_summary(wb, rows) sheet_excluded(wb) sheet_gaps(wb) sheet_questions(wb) args.out.parent.mkdir(parents=True, exist_ok=True) wb.save(args.out) counts = Counter(r["grade"] for r in rows) n_excel = sum(1 for r in corpus if r.get("excel_duplicate")) print(f"생성: {args.out}") print(f" 적재본 {len(corpus)}건 — 컴북스 {n_excel} / 위원회 {len(corpus) - n_excel}") print(f" 리스트업 {len(rows)}건 (A {counts['A']} / B {counts['B']} / C {counts['C']})") print(f" 시트 {len(wb.sheetnames)}개: {', '.join(wb.sheetnames)}") return 0 if __name__ == "__main__": sys.exit(main())