"""판별 대상의 원문과 일치 구간을 뽑아 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())