Files
live-app-translator/src/livesub/models/translator.py
EJClaw f9fd86464d
Some checks failed
Windows / verify (push) Has been cancelled
fix: .gitignore 가 소스 패키지 src/livesub/models 를 통째로 먹고 있었다
`models/` 처럼 슬래시 없이 쓴 규칙은 깊이에 상관없이 같은 이름의 폴더를 전부
무시한다. 의도는 루트의 모델 캐시였는데 소스 패키지까지 같이 걸렸고, 그래서
asr/translator/manager/tiers/glossary/packs/speech_level 8개 파일이 한 번도
커밋된 적이 없다.

증상: Gitea 에서 clone 하면 `ModuleNotFoundError: No module named
'livesub.models'` 로 앱이 아예 임포트되지 않는다. 로컬 작업 트리에는 파일이
있으니 개발 중에는 절대 안 보인다. 방금 붙인 Windows CI VM 이 첫 검증에서
바로 잡아냈다.

/models/, /adapters/, /data/ 로 루트에 고정하고 빠진 파일을 추가한다.
2026-09-25 23:01:31 +09:00

377 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""번역(MT) 래퍼.
두 백엔드를 지원한다.
* CTranslate2 — NLLB 계열 seq2seq. 가볍고 빠르며 추가학습이 쉽다.
* Transformers — Seed-X / Qwen3 같은 LLM. 품질이 좋고 LoRA 어댑터를 얹을 수 있다.
LLM 백엔드는 다시 두 가지 프롬프트 형식으로 갈린다. 같은 "LLM"이라도 요구사항이
정반대라 하나로 합치면 한쪽이 반드시 망가진다.
* PromptStyle.SEEDX — Seed-X 는 chat template 이 없는 **번역 전용 completion 모델**이다.
모델 카드가 정한 고정 문장과 끝의 `<언어코드>` 태그를 그대로 지켜야 하고,
그 밖의 지시문을 끼워 넣으면 학습 분포를 벗어난다. 따라서 용어집은 프롬프트가
아니라 **플레이스홀더 치환**으로 넣는다.
* PromptStyle.INSTRUCT — Qwen3 등. chat template 을 쓰고 용어집을 지시문으로 넣는다.
세 경우 모두 `Translator` 인터페이스를 따르므로 상위 레이어는 구분하지 않는다.
"""
from __future__ import annotations
import abc
import logging
import re
import threading
from ..constants import LANGUAGES, models_dir
from .glossary import Glossary
from .speech_level import Politeness, SpeechLevel, fix_particles, prompt_instruction
from .tiers import MTBackend, MTSpec, PromptStyle
log = logging.getLogger(__name__)
#: 문장 끝에 LLM이 붙이곤 하는 군더더기를 걷어내기 위한 패턴
_TRAILING_NOISE = re.compile(r"^\s*(?:번역[::]|Translation[::])\s*", re.IGNORECASE)
def _restore_or_retranslate(out: str, replacements: list[str], retry) -> str:
"""자리표시자를 역어로 되돌린다. 하나라도 사라졌으면 용어집 없이 다시 번역.
번역 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도 같이
사라진다. 그대로 복원하면 용어가 자막에서 증발하는데, 그건 용어집을
아예 안 쓴 것보다 나쁘다. 그래서 그럴 때는 보호 없이 한 번 더 번역해
원문 내용이 살아 있는 결과를 쓴다.
"""
missing = Glossary.missing_placeholders(out, replacements)
if not missing:
return Glossary.restore(out, replacements)
log.debug("자리표시자 %s 소실 — 용어집 없이 재번역", missing)
return retry()
def build_seedx_prompt(text: str, source_lang: str, target_lang: str) -> str:
"""Seed-X 모델 카드가 요구하는 정확한 프롬프트.
Translate the following English sentence into Chinese:
May the force be with you <zh>
끝의 `<언어코드>` 태그는 PPO 학습에 쓰인 것이라 **필수**다. 빠지면 번역
품질이 크게 흔들린다. 형식을 바꾸면 안 되므로 지시문이나 용어집 설명을
여기 끼워 넣지 말 것.
출처: https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B
"""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
tag = LANGUAGES[target_lang]["seedx"]
return f"Translate the following {src} sentence into {tgt}:\n{text} <{tag}>"
class Translator(abc.ABC):
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
self.spec = spec
self.device = device
self.device_index = device_index
self._lock = threading.Lock()
@property
@abc.abstractmethod
def loaded(self) -> bool: ...
@abc.abstractmethod
def load(self) -> None: ...
@abc.abstractmethod
def unload(self) -> None: ...
@abc.abstractmethod
def _translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None,
speech_level: SpeechLevel,
source_politeness: Politeness,
) -> str: ...
def translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None = None,
speech_level: SpeechLevel = SpeechLevel.POLITE,
source_politeness: Politeness = Politeness.UNKNOWN,
) -> str:
"""말투(speech_level)는 지시문을 이해하는 모델에만 프롬프트로 전달된다.
나머지 백엔드는 상위에서 `apply_speech_level` 후처리로 맞춘다.
"""
text = text.strip()
if not text:
return ""
if source_lang == target_lang:
return text
out = self._translate(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
out = _TRAILING_NOISE.sub("", out).strip()
if target_lang == "ko":
# 용어집이 단어를 바꿔치기하면 앞말 받침이 달라져 조사가 어긋난다.
# (자기장를 -> 자기장을)
out = fix_particles(out)
return out
class CTranslate2Translator(Translator):
"""NLLB 계열. 용어집은 플레이스홀더 보호 방식으로 적용한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
super().__init__(spec, device, device_index)
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import ctranslate2
from huggingface_hub import snapshot_download
from transformers import AutoTokenizer
local = snapshot_download(self.spec.repo, cache_dir=str(models_dir()))
compute = self.spec.compute_type
if self.device == "cpu" and "float16" in compute:
compute = "int8"
log.info("번역 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute)
self._model = ctranslate2.Translator(
local,
device=self.device,
device_index=self.device_index,
compute_type=compute,
)
self._tokenizer = AutoTokenizer.from_pretrained(local, cache_dir=str(models_dir()))
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
def _run_model(self, text: str, source_lang: str, target_lang: str) -> str:
"""용어집 처리를 뺀 순수 번역 한 번."""
src_code = LANGUAGES[source_lang]["nllb"]
tgt_code = LANGUAGES[target_lang]["nllb"]
with self._lock:
self._tokenizer.src_lang = src_code
tokens = self._tokenizer.convert_ids_to_tokens(
self._tokenizer.encode(text, truncation=True, max_length=512)
)
results = self._model.translate_batch(
[tokens],
target_prefix=[[tgt_code]],
beam_size=2,
max_decoding_length=512,
repetition_penalty=1.1,
)
hyp = results[0].hypotheses[0]
if hyp and hyp[0] == tgt_code:
hyp = hyp[1:]
return self._tokenizer.decode(
self._tokenizer.convert_tokens_to_ids(hyp), skip_special_tokens=True
)
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
# seq2seq 는 프롬프트가 없어 말투를 지시할 수 없다. 후처리로 맞춘다.
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
if glossary is None or not len(glossary):
return self._run_model(text, source_lang, target_lang)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return self._run_model(text, source_lang, target_lang)
out = self._run_model(protected, source_lang, target_lang)
return _restore_or_retranslate(
out, replacements,
lambda: self._run_model(text, source_lang, target_lang),
)
class LLMTranslator(Translator):
"""Seed-X / Qwen3 등 LLM 백엔드. 용어집은 프롬프트로 직접 지시한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0,
lora_path: str = "") -> None:
super().__init__(spec, device, device_index)
self.lora_path = lora_path
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
dtype = torch.float16 if self.device == "cuda" else torch.float32
log.info("번역 LLM 로드: %s (%s)", self.spec.repo, self.device)
self._tokenizer = AutoTokenizer.from_pretrained(
self.spec.repo, cache_dir=str(models_dir())
)
try:
self._model = AutoModelForCausalLM.from_pretrained(
self.spec.repo,
cache_dir=str(models_dir()),
torch_dtype=dtype,
device_map={"": self.device_index} if self.device == "cuda" else "cpu",
)
except (ImportError, RuntimeError, ValueError) as exc:
if self.spec.compute_type == "int4":
# AWQ 가중치는 전용 커널이 있어야 읽힌다. 메시지가 모호해서
# 사용자가 원인을 못 찾는 일이 잦으므로 해결 방법을 같이 알려준다.
raise RuntimeError(
f"양자화 번역 모델({self.spec.repo})을 불러오지 못했습니다.\n"
"AWQ 커널이 필요합니다: pip install autoawq\n"
"설치가 어려우면 '모델' 화면에서 다른 티어를 선택하세요.\n"
f"원인: {exc}"
) from exc
raise
if self.lora_path:
from peft import PeftModel
log.info("LoRA 어댑터 적용: %s", self.lora_path)
self._model = PeftModel.from_pretrained(self._model, self.lora_path)
self._model.eval()
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
try:
import torch
torch.cuda.empty_cache()
except Exception: # noqa: BLE001
pass
def _build_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
if self.spec.prompt_style is PromptStyle.SEEDX:
# Seed-X 는 지시문을 못 알아듣는다. 말투는 후처리로만 맞춘다.
return build_seedx_prompt(text, source_lang, target_lang)
return self._build_instruct_prompt(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
def _build_instruct_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
"""지시문을 이해하는 모델용. 가능하면 chat template 을 태운다."""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
hint = glossary.prompt_hint(text, target_lang) if glossary else ""
parts = [
f"Translate the following {src} text into {tgt}. "
"It is a live spoken line from a game or broadcast, so keep the tone "
"casual and natural. Output only the translation."
]
if target_lang == "ko":
parts.append(prompt_instruction(speech_level, source_politeness))
if hint:
parts.append(hint)
parts.append(f"{src}: {text}\n{tgt}:")
instruction = "\n\n".join(parts)
template = getattr(self._tokenizer, "chat_template", None)
if not template:
return instruction
# Qwen3 는 기본적으로 thinking 모드가 켜져 실시간 자막에는 너무 느리다.
try:
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
# enable_thinking 을 모르는 템플릿
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
)
def _generate(self, prompt: str) -> str:
"""프롬프트 하나를 돌려 첫 줄만 돌려준다."""
import torch
with self._lock:
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
with torch.inference_mode():
generated = self._model.generate(
**inputs,
max_new_tokens=256,
do_sample=False,
num_beams=1,
repetition_penalty=1.05,
pad_token_id=self._tokenizer.eos_token_id,
)
new_tokens = generated[0][inputs["input_ids"].shape[-1] :]
out = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
return out.strip().split("\n")[0]
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
def run(source: str, gloss) -> str:
return self._generate(
self._build_prompt(
source, source_lang, target_lang, gloss,
speech_level, source_politeness,
)
)
# Seed-X 는 지시문을 못 알아들으므로 용어집을 자리표시자로 보호한다.
# 지시문을 이해하는 모델은 프롬프트로 넣으므로 보호가 필요 없다.
needs_placeholder = (
self.spec.prompt_style is PromptStyle.SEEDX
and glossary is not None
and len(glossary)
)
if not needs_placeholder:
return run(text, glossary)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return run(text, glossary)
out = run(protected, None)
return _restore_or_retranslate(
out, replacements, lambda: run(text, None)
)
def create_translator(
spec: MTSpec, device: str = "cuda", device_index: int = 0, lora_path: str = ""
) -> Translator:
if spec.backend is MTBackend.CTRANSLATE2:
return CTranslate2Translator(spec, device, device_index)
return LLMTranslator(spec, device, device_index, lora_path)