"""원문 대비 diff 를 클래스 판정해 되돌리는 정밀도 가드 — `diff_revert` rule_type. 파이프라인(FT·LLM 스텝)이 만드는 오탐 중 큰 덩어리가 **기계적**이다 (2026-09-17 chosun C1 풀셋 실측 — paragraph 오탐 410건 중): - 따옴표 글리프 변환 77건: LLM 이 텍스트를 다시 타이핑하며 `" '` 유니코드 따옴표를 ASCII 로 바꾼다. gold 가 이런 수정을 하는 경우는 5건뿐. - 쉼표 삽입 14건: `90일로` -> `90일로,`. gold 쪽 같은 유형 0건. - 금지 치환·격식 전환: `대통령실 -> 청와대`(기관 현재명을 과거명으로), `-다. -> -습니다.`(경어 전환). 교열의 권한 밖. 원문과 현재 텍스트를 어절 단위로 정렬(difflib)해 바뀐 span 마다 위 클래스에 해당하면 원문 span 으로 되돌린다. 화이트리스트 복원(v19/v32)과 같은 계보의 "파이프라인이 만든 변경만 되돌리는" 규칙이라 새 교정을 만들지 않는다. config (전부 선택; 기본은 아래): glyph_restore: true # 글리프-only 치환 되돌림 comma_insertion_revert: true forbidden_insertions: [] # 이 문자열을 새로 들여오는 치환 되돌림 (예: [청와대]) register_guard: false # 평서문 -> 경어(-습니다/-에요) 전환 되돌림 punct_deletion_revert: false # 원문의 따옴표/불릿 문자를 지운 span 되돌림 """ from __future__ import annotations import difflib import re from collections import Counter from typing import Any #: 줄바꿈 보존용 sentinel — 어절 분할 전 개행을 이 토큰으로 바꾼다. #: NUL 은 뉴스 텍스트에 등장하지 않는다. _NL = chr(0) #: 따옴표류 글리프 정규화 — 이것'만' 같아지는 치환은 글리프 변환이다. #: NFKC 는 쓰지 않는다: 단위 문자(㎏ 등)까지 접어서 post_process 의 의도적 #: unit_unicode 변환을 되돌려 버린다. _GLYPH_TRANS = str.maketrans( { "“": '"', "”": '"', "„": '"', "″": '"', "‘": "'", "’": "'", "‚": "'", "′": "'", "´": "'", "`": "'", } ) # -습니다/-ㅂ니다(입니다·갑니다 등)·해요체. '아니다'는 평서형이라 제외한다. _REGISTER_RE = re.compile(r"(? str: return s.translate(_GLYPH_TRANS) def _tokens(text: str) -> list[str]: return text.replace("\n", f" {_NL} ").split() def _join(tokens: list[str]) -> str: return re.sub(f" ?{re.escape(_NL)} ?", "\n", " ".join(tokens)) def _should_revert(orig: str, new: str, config: dict[str, Any]) -> bool: """바뀐 span (orig -> new, 어절 join 문자열) 이 되돌림 클래스인지 판정.""" if orig and new: if config.get("glyph_restore", True) and _norm_glyph(orig) == _norm_glyph(new): return True if ( config.get("comma_insertion_revert", True) and new.replace(",", "") == orig.replace(",", "") and new.count(",") > orig.count(",") ): return True if config.get("punct_deletion_revert", False) and orig and new: removed = Counter(orig) - Counter(new) added = Counter(new) - Counter(orig) if removed and not added and set(removed) <= _STRUCTURAL_MARKS: return True for forbidden in config.get("forbidden_insertions") or (): if forbidden in new and forbidden not in orig: return True return bool( config.get("register_guard", False) and _REGISTER_RE.search(new) and not _REGISTER_RE.search(orig) ) def apply_diff_revert(text: str, original: str, config: dict[str, Any]) -> str: """`diff_revert` rule_type 구현 — 클래스 판정된 diff 를 원문으로 되돌린다.""" orig_tokens = _tokens(original) out_tokens = _tokens(text) matcher = difflib.SequenceMatcher(None, orig_tokens, out_tokens, autojunk=False) rebuilt: list[str] = [] for tag, i1, i2, j1, j2 in matcher.get_opcodes(): if tag == "equal": rebuilt.extend(out_tokens[j1:j2]) continue o_toks = orig_tokens[i1:i2] n_toks = out_tokens[j1:j2] if len(o_toks) == len(n_toks): # difflib 은 연속 변경 어절을 한 블록으로 합친다 — 글리프 변환과 # 실제 교정이 섞인 블록을 통째로 판정하면 둘 다 놓치므로 쌍 단위로. for o, n in zip(o_toks, n_toks, strict=True): rebuilt.append(o if _should_revert(o, n, config) else n) elif _should_revert(" ".join(o_toks), " ".join(n_toks), config): rebuilt.extend(o_toks) else: rebuilt.extend(n_toks) return _join(rebuilt)