.gitignore 가 src/livesub/models 를 통째로 무시하던 동안 이 8개 파일 1,674줄은 커밋에서만 빠진 게 아니라 ruff 검사 범위에서도 빠져 있었다 (ruff 는 기본적으로 .gitignore 를 존중한다). 추적 대상이 되면서 처음 검사에 들어와 나온 2건이다. 같은 파일들에 대해 확인한 것: 프로젝트 룰셋 통과, 전체 룰(--select ALL) 감사에서 정확성 계열(F/E9/PLE) 위반 없음 — 남는 건 프로젝트가 켜지 않은 문서화/스타일 룰뿐, 7개 모듈 import 스모크 전부 통과, tests 10개 파일이 이미 models 를 임포트해 실행 범위에는 들어 있었다.
236 lines
9.8 KiB
Python
236 lines
9.8 KiB
Python
"""용어집(고유명사·게임/방송 용어) 처리.
|
|
|
|
추가학습 없이도 즉시 효과를 내는 레이어다. 두 가지 방식으로 동작한다.
|
|
|
|
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}"
|