From e89cd23ed62b5ea2c40e8ecf0bdcce112a8a19dc Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 3 Sep 2026 09:49:50 +0900 Subject: [PATCH] feat: report original text and judgment criteria for review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검토자가 침해 여부를 눈으로 판별하려면 원문이 있어야 하고, 어떤 조건으로 걸렸는지 알아야 한다. 두 가지를 워크북에 싣는다. - extract_suspected_excerpts.py: 코퍼스 DB(읽기 전용)에서 질의·상대 원문과 일치 구간을 뽑는다. 킹서버 기본 python 이 3.6 이라 이 파일만 구문을 맞췄다. - '판정 기준' 시트: 3개 OR 조건과 결합유사도 가중치, 조건별 충족 현황 - '원문 대조' 시트: 판정 사유 컬럼으로 각 건이 걸린 조건을 표시 판례 표기도 바로잡는다. USE_LLM_LEGAL_JUDGE=false 는 LLM 법적 판단만 끈 것이고 판례 검색은 규칙 기반으로 늘 동작한다. legal_risk.assess() 가 점수 하한 없이 상위 5건을 붙이므로 매칭 미탐지 건에도 판례가 채워진다. 한 줄에 뭉쳐 두면 모순으로 읽혀 판례 검색과 법적 판단을 분리해 적었다. 원문이 담긴 산출물은 gitignore 로 제외한다. 필요하면 위 스크립트로 코퍼스에서 다시 뽑을 수 있어 저장소에 영구 보존할 이유가 없다. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 6 + reports/침해판별_결과보고_20260902.xlsx | Bin 344450 -> 348328 bytes scripts/build_report_xlsx.py | 221 +++++++++++++++++++++++- scripts/extract_suspected_excerpts.py | 138 +++++++++++++++ 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 scripts/extract_suspected_excerpts.py diff --git a/.gitignore b/.gitignore index fc53583..fce38fe 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,9 @@ logs/ # Hugging Face 모델 캐시 .cache/ ~/.cache/huggingface/ + +# 원문(개인 자서전 본문)이 담긴 산출물은 저장소에 두지 않는다. +# 필요하면 scripts/extract_suspected_excerpts.py 로 킹서버 코퍼스에서 다시 뽑는다. +reports/excerpts_*.jsonl +reports/침해판별_원문대조_*.xlsx +reports/침해판별_의심건검토_*.xlsx diff --git a/reports/침해판별_결과보고_20260902.xlsx b/reports/침해판별_결과보고_20260902.xlsx index 33cb9a5ebd7effb3b3c7de2365ab73d00bd00afd..9df3099ab1f8f08b165b1c9101a78b14fe5cf0ae 100644 GIT binary patch delta 19932 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 delta 16018 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 diff --git a/scripts/build_report_xlsx.py b/scripts/build_report_xlsx.py index bd9a19c..f29e080 100644 --- a/scripts/build_report_xlsx.py +++ b/scripts/build_report_xlsx.py @@ -47,7 +47,7 @@ DETAIL_COLUMNS = [ ("lemma 점수", "어휘 유사도", 11), ("주 법령태그", "주 침해유형", 18), ("보조 법령태그", "보조 침해유형", 16), - ("관련 판례 사건번호", "관련 판례 사건번호", 46), + ("관련 판례 사건번호", "관련 판례 사건번호 (관련성 미검증)", 46), ("judgment_summary", "판단 요약", 70), ] @@ -121,6 +121,15 @@ def build_detail_sheet(wb: Workbook, rows: list[dict]) -> None: ) 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: @@ -146,9 +155,11 @@ def build_summary_sheet(wb: Workbook, rows: list[dict], *, backend: str, notes: 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 법률판단 미사용)"), + ("침해 판별", "규칙 기반 (유사도·연속일치·커버리지 3개 조건)"), + ("판례 검색", "규칙 기반. 법령태그·어휘 유사도로 상위 5건을 뽑으며 점수 하한이 없음"), + ("법적 판단", "미수행 (LLM 법률판단 비활성 — 원문을 외부로 전송하지 않음)"), ("검색 백엔드", backend), - ("원문 텍스트", "결과 파일에 저장하지 않음 (가명 식별자만 수록)"), + ("원문 텍스트", "'원문 대조' 시트에 수록 (그 밖의 시트에는 가명 식별자만)"), ): sheet.append([label, value]) @@ -162,6 +173,199 @@ def build_summary_sheet(wb: Workbook, rows: list[dict], *, backend: str, notes: 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차)", @@ -233,6 +437,8 @@ def main() -> int: 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) @@ -243,6 +449,15 @@ def main() -> int: 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) diff --git a/scripts/extract_suspected_excerpts.py b/scripts/extract_suspected_excerpts.py new file mode 100644 index 0000000..10a4b16 --- /dev/null +++ b/scripts/extract_suspected_excerpts.py @@ -0,0 +1,138 @@ +"""판별 대상의 원문과 일치 구간을 뽑아 JSONL로 쓴다. + +배치 결과 파일에는 원문이 없다. 검토자가 실제 침해인지 눈으로 판별하려면 +질의 원문과 매칭 상대 원문, 그리고 둘 사이의 일치 구간이 필요하다. +코퍼스 DB는 읽기 전용으로만 연다. + +킹서버의 기본 python3 가 구버전이라 이 파일만은 표준 라이브러리와 구문 +호환 범위 안에서 쓴다 (타입 표기·f-string 미사용, __future__ import 없음). +저장소의 다른 모듈과 스타일이 다른 이유가 이것이다. + +원문이 포함된 산출물이므로 취급에 주의한다. +""" + +import argparse +import json +import sqlite3 +from difflib import SequenceMatcher + +# app.engine.persistent_index._evidence_spans 와 같은 기준을 쓴다. +MIN_MATCH = 12 +SPAN_LIMIT = 10 + + +def open_readonly(path): + con = sqlite3.connect("file:%s?mode=ro" % path, uri=True) + con.row_factory = sqlite3.Row + return con + + +def fetch_segments(con, segment_ids): + texts = {} + ids = [sid for sid in segment_ids if sid] + for start in range(0, len(ids), 500): + chunk = ids[start:start + 500] + placeholders = ",".join("?" * len(chunk)) + rows = con.execute( + "SELECT segment_id, text FROM segments WHERE segment_id IN (%s)" % placeholders, + chunk, + ) + for row in rows: + texts[row["segment_id"]] = row["text"] + return texts + + +def fetch_document_texts(con, document_ids): + """문서 단위 질의(생활수기)용. 세그먼트를 순서대로 이어 붙인다.""" + texts = {} + for document_id in document_ids: + rows = con.execute( + "SELECT text FROM segments WHERE document_id = ? ORDER BY ordinal, segment_id", + (document_id,), + ).fetchall() + if rows: + texts[document_id] = "\n".join(row["text"] for row in rows) + return texts + + +def matching_spans(query, reference): + if not query or not reference: + return [] + blocks = SequenceMatcher(None, query, reference, autojunk=False).get_matching_blocks() + useful = [b for b in blocks if b.size >= MIN_MATCH] + useful.sort(key=lambda b: (-b.size, b.a)) + selected = sorted(useful[:SPAN_LIMIT], key=lambda b: b.a) + return [query[b.a:b.a + b.size] for b in selected] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database", required=True) + parser.add_argument("--jsonl", required=True, help="배치 결과 JSONL") + parser.add_argument("--out-jsonl", required=True) + parser.add_argument("--max-chars", type=int, default=6000, + help="원문 컬럼 길이 상한. 0 이면 제한 없음") + parser.add_argument("--scope", choices=("all", "suspected"), default="all", + help="all=전 건(미탐지 포함), suspected=침해 의심 건만") + args = parser.parse_args() + + with open(args.jsonl, encoding="utf-8") as handle: + all_rows = [json.loads(line) for line in handle if line.strip()] + if args.scope == "all": + targets = all_rows + else: + targets = [row for row in all_rows if row.get("침해 의심")] + flagged = sum(1 for row in targets if row.get("침해 의심")) + print("대상 %d건 (침해 의심 %d / 매칭 미탐지 %d)" + % (len(targets), flagged, len(targets) - flagged)) + + con = open_readonly(args.database) + try: + segment_ids = set() + document_ids = set() + for row in targets: + for key in ("query_segment_id", "매칭 상대 segment"): + if row.get(key): + segment_ids.add(row[key]) + if not row.get("query_segment_id"): + document_ids.add(row["doc_id"]) + segment_texts = fetch_segments(con, segment_ids) + document_texts = fetch_document_texts(con, document_ids) + finally: + con.close() + + def clip(text): + if args.max_chars and len(text) > args.max_chars: + return text[:args.max_chars] + "\n…(이하 %d자 생략)" % (len(text) - args.max_chars) + return text + + written = 0 + missing = 0 + with open(args.out_jsonl, "w", encoding="utf-8") as handle: + for row in targets: + query_key = row["query_segment_id"] or row["doc_id"] + query_text = segment_texts.get(query_key) or document_texts.get(query_key, "") + source_text = segment_texts.get(row.get("매칭 상대 segment") or "", "") + # 미탐지 건은 매칭 상대가 없는 것이 정상이므로 질의 원문만 확인한다. + if not query_text: + missing += 1 + record = { + "query_key": row["query_key"], + "가명 제목": row["가명 제목"], + "침해 여부": row["침해 여부"], + "매칭 상대 제목": row.get("매칭 상대 제목"), + "원천 구분": row["원천 구분"], + "결합유사도": row["결합유사도"], + "근거 스팬 수": row.get("evidence 스팬 수"), + "일치 구간": matching_spans(query_text, source_text), + "검사 대상 원문": clip(query_text), + "매칭 상대 원문": clip(source_text), + } + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + written += 1 + print("wrote %s rows=%d 원문누락=%d" % (args.out_jsonl, written, missing)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())