""" span_postproc.py — Q3 경계오류 CHEAP 후처리 (재학습 0). litreview 2026-06-04 §3 + q3-boundary-fix-plan. CHEAP-1: 한글(漢字) 주석 / 짝 안 맞는 괄호 꼬리를 PS/OG/LC 고유명에서 트리밍. 한국어 혼용표기 '한글(漢字)' 관례상 한자 주석은 mention 에 미포함 → '섬서(陝西' → '섬서'. 순수 한자 고유명(한글 prefix 없음)은 보존 — 한글 prefix 가 있을 때만 한자/괄호 꼬리 제거. CHEAP-3: type-aware stoplist (gold FP 시드, 보수적). 일반어/지시어만, gold 정답과 무충돌인 쌍만. 문맥 의존 어휘(정부=실제 기관일 수도, 한 건=gold 가 QT 로 원함)는 *제외* — 과억제 방지. 적용 위치: 런타임/평가 추출 *마지막* (idiom_filter 다음, canon+model+regex+idiom 병합 후). NER 모델/regex/canon/silver 는 안 건드림 — 순수 출력 후처리. 공유: 파이썬 평가경로(eval_full_pipeline·gold_eval) + 서버(hf-space/app.py) + (포팅 예정) plugin ner.js. app.py 는 offset(start/end) 동반이라 trim_span_offsets 로 surface+offset 동시 트리밍. self-test: 현재 gold-multibot FP 케이스가 의도대로 트리밍/억제되는지. 실행: py span_postproc.py from span_postproc import postprocess, trim_affix, nfc """ import sys import unicodedata try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass # ── CHEAP-1: 괄호/한자 꼬리 트리밍 ── _OPEN = "(([[〈「「『{<<" _CLOSE = "))]]〉」」』}>>" def _is_hangul(c): return "가" <= c <= "힣" def has_hangul(s): """문자열에 한글 음절이 하나라도 있나 — 한자-only/괄호 잔재(예: pipeline 이 쪼갠 '陝西') 판별용.""" return any(_is_hangul(c) for c in (s or "")) def _is_cjk(c): """CJK 통합한자(+확장 A/호환). 한글 prefix 뒤 한자 주석 판정용.""" o = ord(c) return (0x3400 <= o <= 0x9FFF) or (0xF900 <= o <= 0xFAFF) or (0x20000 <= o <= 0x2FA1F) def trim_affix(surface): """PS/OG/LC surface 의 한자 주석/괄호 꼬리 제거. 한글 prefix 가 있을 때만 자름. '섬서(陝西' → '섬서' · '섬서(陝西)' → '섬서' · '한강(漢江)' → '한강' '오련표' → '오련표' (변화 없음) · '陝西' → '陝西' (순수 한자 보존) """ s = (surface or "").strip() if not s: return s # 한글 prefix + (여는괄호 | 한자블록) 시작 지점에서 컷 (그 앞에 한글이 실재할 때만) if any(_is_hangul(c) for c in s): for i, c in enumerate(s): if (c in _OPEN or _is_cjk(c)) and any(_is_hangul(p) for p in s[:i]): s = s[:i] break # 양끝에 남은 괄호/공백 정리 (짝 안 맞는 여는·닫는 괄호) s = s.strip().strip(_OPEN + _CLOSE).strip() return s def trim_span_offsets(text, start, end): """offset 동반 트리밍 (app.py / plugin parity). (start, end, surface) 반환. surface = text[start:end] 를 trim_affix 한 뒤, 잘려나간 만큼 end 를 당겨 offset 일관 유지. 앞쪽이 잘리는 경우(닫는 괄호로 시작 등)는 start 도 전진. """ surf = text[start:end] trimmed = trim_affix(surf) if trimmed == surf: return start, end, surf # trimmed 는 surf 의 연속 부분문자열 — 앞/뒤 잘림 폭 계산 off = surf.find(trimmed) if trimmed else 0 new_start = start + off new_end = new_start + len(trimmed) return new_start, new_end, trimmed # ── CHEAP-3: type-aware stoplist (보수적) ── # 각 쌍은 현재 gold-multibot FP 에 실재 + gold 정답과 무충돌. 문맥 의존어는 의도적으로 제외. STOP_PAIRS = { ("그날", "DT"), # 지시 표현 (괴이탐사 '그날 저녁') ("서방", "PS"), # 호칭 '서방님' ("하나", "PS"), # 수사 '단 하나' (gold 는 하나=QT 로 원함 → PS 만 차단) ("두 문파", "QT"), # 일반 수량+명사 ('화산파와 종남파 = 두 문파') ("이대제자", "PS"), # 무협 제자 등급 (일반 명사, 인물 아님) } def is_stopword(surface, typ): return (surface, typ) in STOP_PAIRS # ── 통합 후처리 ── _TRIM_TYPES = ("PS", "OG", "LC") # 한자 주석 관례는 고유명에만 (DT/TI/QT 는 괄호 안 숫자가 정당) def postprocess(preds): """[(surface, type), ...] → CHEAP-1 트리밍 + CHEAP-3 stoplist 적용. idiom_filter 다음 자리. 트리밍으로 빈 문자열이 되면 드롭. 트리밍이 만든 (surface,type) 중복도 제거. """ out = [] seen = set() for p in preds: surf, typ = p[0], p[1] if typ in _TRIM_TYPES: s = trim_affix(surf) if not has_hangul(s): # 트리밍 후 한글 0 = 한자/괄호 잔재(pipeline 이 쪼갠 '陝西' 등) → 드롭 continue else: s = (surf or "").strip() if not s: continue if is_stopword(s, typ): continue if (s, typ) in seen: continue seen.add((s, typ)) out.append((s, typ)) return out def nfc(text): """CHEAP-4: 입력/gold 를 NFC 로 통일 (전각·결합문자 offset 어긋남 방지).""" return unicodedata.normalize("NFC", text or "") if __name__ == "__main__": trim_cases = [ ("섬서(陝西", "섬서"), ("섬서(陝西)", "섬서"), ("섬서(", "섬서"), ("한강(漢江)", "한강"), ("에단 킴", "에단 킴"), # 변화 없음 ("오련표", "오련표"), # 변화 없음 ("陝西", "陝西"), # 순수 한자 보존 (한글 prefix 없음) ("오리엔테이션 룸", "오리엔테이션 룸"), ] ok = 0 print("[trim] CHEAP-1 한자/괄호 꼬리 트리밍:") for src, exp in trim_cases: got = trim_affix(src) flag = "OK" if got == exp else "XX" ok += got == exp print(f" [{flag}] trim_affix({src!r}) = {got!r} (기대 {exp!r})") stop_cases = [ (("하나", "PS"), True), (("하나", "QT"), False), # gold 정답 — 보존 (("그날", "DT"), True), (("서방", "PS"), True), (("두 문파", "QT"), True), (("이대제자", "PS"), True), (("정부", "OG"), False), # gold 정답 — 보존 (("한 건", "QT"), False), # gold 정답 — 보존 ] print("\n[stop] CHEAP-3 type-aware stoplist:") for (surf, typ), exp in stop_cases: got = is_stopword(surf, typ) flag = "OK" if got == exp else "XX" ok += got == exp print(f" [{flag}] is_stopword({surf!r}, {typ!r}) = {got} (기대 {exp})") # 통합: 섬서( 가 트리밍되어 gold 와 매칭되는지 + offset 트리밍 print("\n[post] 통합 후처리:") pp = postprocess([("섬서(", "LC"), ("陝西", "LC"), ("하나", "PS"), ("그날", "DT"), ("에단 킴", "PS")]) exp_pp = [("섬서", "LC"), ("에단 킴", "PS")] # 陝西(한자only)·하나(PS)·그날(DT) 드롭 flag = "OK" if pp == exp_pp else "XX" ok += pp == exp_pp print(f" [{flag}] postprocess(...) = {pp} (기대 {exp_pp})") s0, e0, surf0 = trim_span_offsets("섬서(陝西)의 거리는", 0, 5) # '섬서(陝西' span flag = "OK" if surf0 == "섬서" and (s0, e0) == (0, 2) else "XX" ok += surf0 == "섬서" and (s0, e0) == (0, 2) print(f" [{flag}] trim_span_offsets = ({s0},{e0},{surf0!r}) (기대 (0,2,'섬서'))") total = len(trim_cases) + len(stop_cases) + 2 print(f"\n[span_postproc] self-test {ok}/{total} 통과")