diff --git a/docs/PRECEDENT_LISTUP.md b/docs/PRECEDENT_LISTUP.md index 135f742..251755f 100644 --- a/docs/PRECEDENT_LISTUP.md +++ b/docs/PRECEDENT_LISTUP.md @@ -191,7 +191,10 @@ ## 8. 첨부·원본 위치 -- 본 리스트업 표(CSV, Excel에서 바로 열림): `data/precedents/precedent_listup.csv` +- **배포용 Excel(전달 첨부): `docs/판례_리스트업.xlsx`** — 5개 시트 + (판례 목록 / 요약·방법 / 제외 사유 / 데이터 공백 / 상담 질의). + `scripts/build_precedent_listup_xlsx.py` 로 재생성한다. +- 원본 표(CSV): `data/precedents/precedent_listup.csv` - 운영 적재본 545건: `data/precedents/precedents.jsonl` - 수집·제외 통계: `data/precedents/selection_audit.json` - 데이터 계층 전체 인벤토리: `docs/PRECEDENT_DATA.md` diff --git a/docs/판례_리스트업.xlsx b/docs/판례_리스트업.xlsx new file mode 100644 index 0000000..1a92088 Binary files /dev/null and b/docs/판례_리스트업.xlsx differ diff --git a/scripts/build_precedent_listup_xlsx.py b/scripts/build_precedent_listup_xlsx.py new file mode 100644 index 0000000..ab976e5 --- /dev/null +++ b/scripts/build_precedent_listup_xlsx.py @@ -0,0 +1,472 @@ +#!/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())