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:
141
tests/test_portable.py
Normal file
141
tests/test_portable.py
Normal 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
|
||||
Reference in New Issue
Block a user