"""용어집(고유명사·게임/방송 용어) 처리. 추가학습 없이도 즉시 효과를 내는 레이어다. 두 가지 방식으로 동작한다. 1. 플레이스홀더 보호 (`protect` / `restore`) 원문의 등록 용어를 `⟦0⟧` 같은 토큰으로 치환해 번역 모델이 건드리지 못하게 하고, 번역이 끝난 뒤 지정한 역어로 되돌린다. 어떤 모델에서도 쓸 수 있다. 2. 프롬프트 주입 (`prompt_hint`) 해당 문장에 등장한 용어만 골라 프롬프트에 "이 용어는 이렇게 옮겨라"로 넣는다. 조사·어미까지 문맥에 맞게 붙어 결과가 더 자연스럽다. 어느 쪽을 쓸지는 모델이 정한다. 2번은 **지시문을 이해하는 instruct 모델에서만** 동작한다. Seed-X 처럼 번역만 하도록 학습된 completion 모델은 지시문을 넣으면 학습 분포를 벗어나 오히려 번역이 망가지므로 1번을 쓴다. NLLB 같은 seq2seq 도 마찬가지다. 판정은 `MTSpec.supports_prompt_glossary` 가 한다. """ from __future__ import annotations import json import re from dataclasses import dataclass, field from pathlib import Path #: 번역 모델이 원문 그대로 통과시켜야 하는 자리표시자. #: #: 형식 선택은 취향이 아니라 실측 결과다. 처음에 쓰던 `⟦0⟧` 는 NLLB 가 #: 괄호를 통째로 날려 `0` 만 남겼고(생존률 0/3), 그 결과 용어가 자막에서 #: 사라졌다. 후보 8종을 실제 모델로 돌려 비교한 끝에 `#0#` 를 골랐다 #: (생존률 3/3, 다중 치환 4/5). 입력이 음성인식 결과라 `#` 가 자연스럽게 #: 등장할 일이 거의 없다는 점도 같이 봤다. PLACEHOLDER = "#{}#" # #0# PLACEHOLDER_RE = re.compile(r"#(\d+)#") @dataclass class GlossaryEntry: """용어 하나. `targets` 는 언어코드 → 역어.""" source: str targets: dict[str, str] = field(default_factory=dict) note: str = "" #: 비우면 모든 소스 언어에 적용 source_lang: str = "" #: 문장 전체가 이 표현일 때만 치환한다. #: #: "get in the car" 같은 절을 긴 문장 한가운데서 통째로 치환하면 #: 번역 모델이 문법을 세울 근거를 잃어 "어부 들어오는, 차 타!" 같은 #: 결과가 나온다. 반대로 그 말만 단독으로 나왔을 때는 통째로 바꾸는 #: 것이 가장 자연스럽다. 그래서 적용 범위를 문장 전체로 한정한다. whole_only: bool = False def target_for(self, lang: str) -> str: return self.targets.get(lang, "") class Glossary: def __init__(self, entries: list[GlossaryEntry] | None = None, case_sensitive: bool = False): self.case_sensitive = case_sensitive self.entries: list[GlossaryEntry] = list(entries or []) self._pattern: re.Pattern[str] | None = None self._index: dict[str, GlossaryEntry] = {} self._rebuild() # --- 영속화 -------------------------------------------------------- @classmethod def load(cls, path: str | Path, case_sensitive: bool = False) -> Glossary: p = Path(path) if not p.is_file(): return cls(case_sensitive=case_sensitive) raw = json.loads(p.read_text(encoding="utf-8")) entries = [ GlossaryEntry( source=item["source"], targets=dict(item.get("targets", {})), note=item.get("note", ""), source_lang=item.get("source_lang", ""), whole_only=bool(item.get("whole_only", False)), ) for item in raw.get("entries", []) if item.get("source") ] return cls(entries, case_sensitive=raw.get("case_sensitive", case_sensitive)) def save(self, path: str | Path) -> Path: p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) payload = { "case_sensitive": self.case_sensitive, "entries": [ { "source": e.source, "targets": e.targets, "note": e.note, "source_lang": e.source_lang, "whole_only": e.whole_only, } for e in self.entries ], } p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") return p # --- 편집 ---------------------------------------------------------- def add(self, entry: GlossaryEntry) -> None: key = self._key(entry.source) self.entries = [e for e in self.entries if self._key(e.source) != key] self.entries.append(entry) self._rebuild() def remove(self, source: str) -> None: key = self._key(source) self.entries = [e for e in self.entries if self._key(e.source) != key] self._rebuild() def __len__(self) -> int: return len(self.entries) # --- 사용 ---------------------------------------------------------- def matches(self, text: str, target_lang: str) -> list[GlossaryEntry]: """문장에 실제로 등장하고 해당 언어 역어가 있는 항목만.""" if not self._pattern or not text: return [] found: list[GlossaryEntry] = [] seen: set[str] = set() for m in self._pattern.finditer(text): entry = self._index.get(self._key(m.group(0))) if entry is None or not entry.target_for(target_lang): continue key = self._key(entry.source) if key not in seen: seen.add(key) found.append(entry) return found def protect(self, text: str, target_lang: str) -> tuple[str, list[str]]: """등록 용어를 자리표시자로 치환. (치환된 문장, 역어 목록) 반환.""" replacements: list[str] = [] if not text: return text, replacements whole = self._match_whole(text, target_lang) if whole is not None: # 문장 전체가 등록된 표현 — 통째로 바꾸는 게 가장 자연스럽다. return PLACEHOLDER.format(0), [whole] if not self._pattern: return text, replacements def _sub(m: re.Match[str]) -> str: entry = self._index.get(self._key(m.group(0))) if entry is None: return m.group(0) target = entry.target_for(target_lang) if not target: return m.group(0) replacements.append(target) return PLACEHOLDER.format(len(replacements) - 1) return self._pattern.sub(_sub, text), replacements @staticmethod def surviving_placeholders(text: str) -> set[int]: """번역 결과에 살아남은 자리표시자 번호.""" return {int(m) for m in PLACEHOLDER_RE.findall(text)} @classmethod def missing_placeholders(cls, text: str, replacements: list[str]) -> list[int]: """번역 중에 사라진 자리표시자 번호. 번역 모델이 문장 일부를 통째로 누락하면 자리표시자도 같이 사라진다. 그대로 복원하면 해당 용어가 자막에서 증발하므로, 호출부가 이걸로 확인해 용어집 없이 다시 번역하는 쪽을 택할 수 있게 한다. """ present = cls.surviving_placeholders(text) return [i for i in range(len(replacements)) if i not in present] @staticmethod def restore(text: str, replacements: list[str]) -> str: """플레이스홀더를 역어로 되돌린다.""" if not replacements: return text def _sub(m: re.Match[str]) -> str: idx = int(m.group(1)) return replacements[idx] if 0 <= idx < len(replacements) else "" return PLACEHOLDER_RE.sub(_sub, text) def prompt_hint(self, text: str, target_lang: str, limit: int = 12) -> str: """LLM 프롬프트에 붙일 용어 지시문. 해당 없으면 빈 문자열.""" hits = self.matches(text, target_lang)[:limit] if not hits: return "" lines = [f"- {e.source} -> {e.target_for(target_lang)}" for e in hits] return "다음 용어는 반드시 지정된 역어로 번역하세요:\n" + "\n".join(lines) def _match_whole(self, text: str, target_lang: str) -> str | None: """문장 전체가 등록 표현과 같으면 그 역어를 돌려준다.""" stripped = self._key(text.strip().rstrip(".!?…~,")) entry = self._index.get(stripped) if entry is None: return None return entry.target_for(target_lang) or None # --- 내부 ---------------------------------------------------------- def _key(self, text: str) -> str: return text if self.case_sensitive else text.casefold() def _rebuild(self) -> None: self._index = {self._key(e.source): e for e in self.entries} if not self.entries: self._pattern = None return # whole_only 항목은 부분 매칭에서 제외한다 (문장 전체일 때만 쓴다). partial = [e.source for e in self.entries if not e.whole_only] if not partial: self._pattern = None return # 긴 용어를 먼저 매칭해야 "헤드샷"이 "헤드"에 잡아먹히지 않는다. sources = sorted(partial, key=len, reverse=True) flags = 0 if self.case_sensitive else re.IGNORECASE self._pattern = re.compile( "|".join(_bounded(re.escape(s)) for s in sources), flags ) def _bounded(escaped: str) -> str: """영문/숫자로 시작·끝나는 용어에만 단어 경계를 건다. 한국어·일본어·중국어에는 \\b 가 의미 없으므로 적용하지 않는다. """ prefix = r"\b" if re.match(r"[A-Za-z0-9]", escaped.replace("\\", "")) else "" suffix = r"\b" if re.search(r"[A-Za-z0-9]$", escaped.replace("\\", "")) else "" return f"{prefix}{escaped}{suffix}"