fix: 포터블에 transformers 포함 + 실제 모델로 드러난 용어집 결함 수정

리뷰 지적(포터블에서 transformers 제외 -> NLLB 로드 실패)을 고치려고 실제
NLLB 를 받아 돌려봤고, 그 과정에서 용어집이 사실상 동작하지 않고 있었다는
것을 발견했다. 단위 테스트는 "모델이 자리표시자를 통과시킨다"는 틀린 전제
위에 서 있었다.

1) 포터블 패키징 (리뷰 지적)
   transformers 를 제외 목록에서 빼고 hiddenimports 에 넣었다. NLLB
   토크나이저가 AutoTokenizer 를 쓰기 때문이다. transformers 는 torch 가
   없으면 토크나이저 전용 모드로 뜨며 그게 우리 용도와 정확히 맞는다.
   torch 없는 환경에서 ctranslate2/transformers/faster-whisper import 와
   앱 기동을 검증하는 test_portable.py 를 추가했다.

2) 자리표시자 형식 (실측으로 발견)
   `⟦0⟧` 는 NLLB 가 괄호를 날려 생존률 0/3 이었다. 용어가 자막에서 그냥
   사라지고 있었다 ("Third party incoming" -> "0 들어오는"). 후보 8종을
   실제 모델로 비교해 `#0#` 로 교체 (3/3, 다중 4/5).

3) 소실 대비
   모델이 문장 일부를 누락하면 자리표시자도 사라진다. 그대로 복원하면
   용어가 증발하므로, 하나라도 없으면 보호 없이 재번역한다.

4) 서술어는 문장 전체일 때만 (whole_only)
   절/서술어를 문장 중간에서 치환하면 문법이 무너진다.
     before: "탄 필요해와 구급상자"
     after : "탄약과 구급상자가 필요합니다"
   해당 56개 항목을 whole_only 로 지정해 단독 발화일 때만 적용한다.
   ("Cover me!" -> "엄호해줘" 는 그대로 유지)

5) 조사 교정
   역어 받침이 달라 "자기장를" 이 남던 것을 fix_particles() 로 고친다.
   을/를, 이/가, 은/는, 과/와, (으)로 — 한글 코드에서 받침을 읽어 판정.

검증: pytest 189개 통과, ruff clean
      실제 NLLB-600M(torch 없이 CPU)로 번역 품질 직접 확인

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-09-23 01:20:42 +09:00
parent 71436843d8
commit bede94ee48
18 changed files with 370 additions and 64 deletions

View File

@@ -72,4 +72,22 @@ def test_add_replaces_existing_entry():
def test_restore_ignores_out_of_range_placeholder():
assert Glossary.restore("a ⟦5⟧ b", ["x"]) == "a b"
assert Glossary.restore("a #5# b", ["x"]) == "a b"
def test_missing_placeholder_is_reported():
"""번역이 문장 일부를 날리면 자리표시자도 사라진다 — 호출부가 알아야 한다."""
assert Glossary.missing_placeholders("#0# 만 남음", ["가", "나"]) == [1]
assert Glossary.missing_placeholders("#0# #1#", ["가", "나"]) == []
assert Glossary.missing_placeholders("아무것도 없음", ["가"]) == [0]
def test_placeholder_format_is_the_measured_one():
"""NLLB 실측에서 살아남은 형식(#0#)을 유지해야 한다.
`⟦0⟧` 같은 특수 괄호는 NLLB 가 통째로 날려 용어가 자막에서 사라졌다.
형식을 바꾸려면 실제 모델로 생존률을 다시 재고 이 테스트를 고칠 것.
"""
g = Glossary([GlossaryEntry("baron", {"ko": "바론"})])
protected, _ = g.protect("take baron", "ko")
assert protected == "take #0#"

View File

@@ -178,8 +178,27 @@ def test_default_enabled_packs_actually_translate():
cfg = AppConfig()
merged = build_glossary(Glossary(), cfg.glossary.enabled_packs)
# 'hacked' 는 서술어라 문장 중간에서는 치환하지 않는다 (whole_only).
_, repl = merged.protect("DedSec hacked the ctOS server", "ko")
assert repl == ["데드섹", "해킹됐다", "ctOS"]
assert repl == ["데드섹", "ctOS"]
def test_predicate_entries_only_apply_to_whole_utterance():
"""서술어를 문장 한가운데서 치환하면 번역 문법이 무너진다.
"I need ammo and a med kit." 에서 'need ammo' 를 통째로 바꾸면
"탄 필요해와 구급상자" 가 됐다. 명사만 바꾸면 "탄약과 구급상자가
필요합니다" 로 제대로 나온다 (실제 NLLB 로 확인).
"""
from livesub.config import AppConfig
merged = build_glossary(Glossary(), AppConfig().glossary.enabled_packs)
_, repl = merged.protect("I need ammo and a med kit.", "ko")
assert repl == ["탄약", "구급상자"]
# 반대로 그 말만 단독으로 나오면 통째로 바꾸는 게 자연스럽다.
_, repl = merged.protect("Cover me!", "ko")
assert repl == ["엄호해줘"]
# --- 용어가 조각나서 두 번 치환되지 않는지 -----------------------------------

141
tests/test_portable.py Normal file
View File

@@ -0,0 +1,141 @@
"""포터블 빌드(= torch 없는 환경) 검증.
포터블 exe 는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라 onefile 로
묶으면 실행할 때마다 임시폴더에 푸느라 못 쓸 만큼 느려지기 때문이다.
그 대신 **1~3티어(CTranslate2)는 torch 없이 전부 돌아야 한다.** 이 파일은
그 전제가 깨지지 않았는지 지킨다. 특히 `CTranslate2Translator.load()` 가
torch 없이 통과하는지 — 한 번 이걸 놓쳐서 포터블이 실행 즉시 죽을 뻔했다.
"""
from __future__ import annotations
import ast
import importlib
import importlib.util
from pathlib import Path
import pytest
from livesub.models.manager import first_available_tier, has_torch, tier_availability
from livesub.models.tiers import TIERS, MTBackend, ordered_tiers
SPEC = Path(__file__).resolve().parents[1] / "packaging" / "livesub.spec"
PORTABLE_REQS = Path(__file__).resolve().parents[1] / "packaging" / "requirements-portable.txt"
#: CTranslate2 경로가 실제로 import 하는 것들. 포터블에 반드시 들어가야 한다.
CT2_RUNTIME_IMPORTS = ["ctranslate2", "transformers", "huggingface_hub", "faster_whisper"]
def _spec_list(name: str) -> list[str]:
"""spec 파일에서 excludes / hiddenimports 리스트를 정적으로 읽는다."""
tree = ast.parse(SPEC.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == name:
return [
e.value for e in node.value.elts if isinstance(e, ast.Constant)
]
raise AssertionError(f"spec 에 {name} 가 없습니다")
def test_spec_excludes_torch():
"""torch 가 들어가면 exe 가 3GB 로 불어나 포터블의 의미가 사라진다."""
assert "torch" in _spec_list("excludes")
def test_spec_does_not_exclude_transformers():
"""NLLB 토크나이저가 transformers 를 쓰므로 제외하면 안 된다.
한 번 제외했다가 포터블에서 번역 모델 로드가 통째로 실패할 뻔했다.
"""
assert "transformers" not in _spec_list("excludes")
@pytest.mark.parametrize("module", CT2_RUNTIME_IMPORTS)
def test_ct2_runtime_deps_are_in_portable_requirements(module):
text = PORTABLE_REQS.read_text(encoding="utf-8")
name = module.replace("_", "-")
assert name in text or module in text, f"{module} 가 포터블 의존성에 없습니다"
def test_portable_requirements_have_no_torch():
text = PORTABLE_REQS.read_text(encoding="utf-8").lower()
for line in text.splitlines():
line = line.split("#")[0].strip()
assert not line.startswith("torch"), f"포터블에 torch 가 있습니다: {line}"
# --- torch 없는 환경에서의 실제 동작 -----------------------------------------
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
def test_ct2_translator_loads_without_torch():
"""리뷰에서 지적된 지점 — 포터블에서 load() 가 죽으면 앱을 못 쓴다.
모델 가중치는 받지 않고, load() 가 실제로 부르는 import 들이 torch 없이
통과하는지만 본다.
"""
for module in CT2_RUNTIME_IMPORTS:
assert importlib.util.find_spec(module) is not None, f"{module} 없음"
from transformers import AutoTokenizer # noqa: F401 - import 자체가 검증
importlib.import_module("ctranslate2")
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
def test_ct2_tiers_are_usable_without_torch():
for tier in ordered_tiers():
if tier.mt.backend is MTBackend.CTRANSLATE2:
assert tier_availability(tier)[0], f"{tier.key} 가 포터블에서 막혔습니다"
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
def test_llm_tiers_are_blocked_without_torch():
"""쓸 수 없는 티어는 '사용 불가'로 막고 이유를 알려줘야 한다."""
for tier in TIERS.values():
if tier.mt.backend is MTBackend.TRANSFORMERS:
ok, reason = tier_availability(tier)
assert not ok
assert "PyTorch" in reason
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
def test_default_tier_is_usable_in_portable():
"""기본 티어가 포터블에서 못 쓰는 것이면 첫 실행부터 막힌다."""
from livesub.config import AppConfig
assert tier_availability(TIERS[AppConfig().models.tier])[0]
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
def test_unavailable_tier_falls_back_to_best_usable():
assert first_available_tier("precision") == "balance"
assert first_available_tier("ultimate") == "balance"
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
def test_app_window_builds_without_torch(tmp_path, monkeypatch):
"""포터블에서 앱 전체가 뜨는지 (오디오 장치도 GPU도 없이)."""
pytest.importorskip("PySide6")
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from livesub.config import AppConfig
from livesub.ui.main_window import MainWindow
app = QApplication.instance() or QApplication([])
cfg = AppConfig()
cfg.models.preload_on_start = False
cfg.glossary.path = str(tmp_path / "g.json")
win = MainWindow(cfg)
try:
assert win.stack.count() == 5
finally:
win.engine.shutdown()
win.overlay.close()
win.close()
_ = app

View File

@@ -79,4 +79,4 @@ def test_glossary_survives_seedx_placeholder_path():
assert prompt.endswith(" <ko>")
# 모델이 플레이스홀더를 그대로 통과시켰다고 가정
assert Glossary.restore("⟦0⟧로 밀어", repl) == "넥서스로 밀어"
assert Glossary.restore("#0#로 밀어", repl) == "넥서스로 밀어"

View File

@@ -240,3 +240,39 @@ def test_unknown_speech_level_value_falls_back_to_polite(monkeypatch, tmp_path):
assert _run(monkeypatch, tmp_path, "무엇인가이상한값", "Enemy", "en") == (
"적이 왼쪽에서 옵니다"
)
# --- 조사 교정 --------------------------------------------------------------
@pytest.mark.parametrize(
("wrong", "right"),
[
("자기장를 봐요", "자기장을 봐요"),
("바론를 치자", "바론을 치자"),
("데드섹가 해킹했다", "데드섹이 해킹했다"),
("구급상자을 주세요", "구급상자를 주세요"),
("넥서스은 저기", "넥서스는 저기"),
("서울으로 간다", "서울로 간다"),
("칼으로 베다", "칼로 베다"),
("적으로 간다", "적으로 간다"),
],
)
def test_particles_are_corrected(wrong, right):
from livesub.models.speech_level import fix_particles
assert fix_particles(wrong) == right
def test_correct_particles_are_left_alone():
from livesub.models.speech_level import fix_particles
for text in ["적이 왼쪽에서 온다", "넥서스를 밀어", "스파이크를 설치했다", "바론을 치자"]:
assert fix_particles(text) == text
def test_particle_fix_ignores_non_hangul_and_empty():
from livesub.models.speech_level import fix_particles
assert fix_particles("") == ""
assert fix_particles("ctOS를 해킹") == "ctOS를 해킹"