#!/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 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 # --------------------------------------------------------------------------- # 시트 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_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("--out", type=Path, default=ROOT / "docs/판례_리스트업.xlsx") args = ap.parse_args() rows = load_rows(args.csv) wb = Workbook() wb.remove(wb.active) # 기본 시트 제거 sheet_list(wb, rows) 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) print(f"생성: {args.out}") 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())