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

@@ -98,11 +98,11 @@ Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
| | 포터블 (권장) | 일반 설치 | | | 포터블 (권장) | 일반 설치 |
|---|---|---| |---|---|---|
| 준비물 | `LiveSub.exe` 하나 | Python + PyTorch | | 준비물 | `LiveSub.exe` 하나 | Python + PyTorch |
| 크기 | 약 500MB | 약 3GB | | 크기 | 약 500~700MB | 약 3GB |
| 쓸 수 있는 티어 | 1~3 | 1~5 전부 | | 쓸 수 있는 티어 | 1~3 | 1~5 전부 |
| 번역 품질 | NLLB | NLLB + Seed-X / Qwen3 | | 번역 품질 | NLLB | NLLB + Seed-X / Qwen3 |
포터블은 PyTorch를 빼서 만듭니다. PyTorch+CUDA만 2.5GB라 한 파일로 묶으면 포터블은 PyTorch를 빼서 만듭니다 (토크나이저용 `transformers`는 포함). PyTorch+CUDA만 2.5GB라 한 파일로 묶으면
**실행할 때마다** 그걸 임시폴더에 푸느라 1분 넘게 걸리기 때문입니다. **실행할 때마다** 그걸 임시폴더에 푸느라 1분 넘게 걸리기 때문입니다.
음성인식도 번역도 CTranslate2 위에서 돌아가 PyTorch가 필요 없으므로, 음성인식도 번역도 CTranslate2 위에서 돌아가 PyTorch가 필요 없으므로,
1~3티어는 포터블에서 그대로 다 됩니다. 1~3티어는 포터블에서 그대로 다 됩니다.

View File

@@ -106,6 +106,25 @@ WASAPI의 `ActivateAudioInterfaceAsync` + `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_L
자기장"이 된다. `test_each_term_protects_as_a_single_unit` 이 모든 팩을 켠 자기장"이 된다. `test_each_term_protects_as_a_single_unit` 이 모든 팩을 켠
상태로 이 겹침을 잡는다. 상태로 이 겹침을 잡는다.
### 용어집은 실제 모델로 검증해야 한다
이 기능은 단위 테스트만으로는 검증되지 않는다. "번역 모델이 자리표시자를
그대로 통과시킨다"는 전제 자체가 틀릴 수 있기 때문이다. 실제 NLLB 를 돌려
보고 나서야 드러난 것들:
1. **자리표시자 형식.** 처음 쓰던 `⟦0⟧` 는 NLLB 가 괄호를 통째로 날려
`0` 만 남겼다(생존률 0/3). 후보 8종을 실측해 `#0#` 로 바꿨다(3/3).
그전까지 용어가 자막에서 그냥 사라지고 있었다.
2. **소실 대비.** 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도
사라진다. 그대로 복원하면 용어가 증발하는데 이건 용어집을 안 쓴 것보다
나쁘다. 그래서 하나라도 사라지면 보호 없이 한 번 더 번역한다.
3. **서술어는 문장 전체일 때만.** "need ammo" 를 문장 한가운데서 치환하면
모델이 문법을 세울 근거를 잃어 "탄 필요해와 구급상자" 가 된다. 명사만
바꾸면 "탄약과 구급상자가 필요합니다" 로 제대로 나온다. 그래서 절·서술어
항목 56개는 `whole_only` 로 두어 그 말만 단독으로 나왔을 때만 적용한다.
4. **조사 어긋남.** 역어의 받침이 자리표시자와 다르면 "자기장를" 이 남는다.
받침은 한글 코드에서 바로 읽히므로 `fix_particles()` 로 정확히 고친다.
### 배치 계산을 Qt에서 떼어냈다 ### 배치 계산을 Qt에서 떼어냈다
`placement.py` 는 `ScreenInfo` 라는 순수 데이터만 받아 좌표를 계산한다. 덕분에 `placement.py` 는 `ScreenInfo` 라는 순수 데이터만 받아 좌표를 계산한다. 덕분에

View File

@@ -7,7 +7,11 @@
그래서 포터블 빌드는 torch 를 뺀다. 음성인식(faster-whisper)과 번역 그래서 포터블 빌드는 torch 를 뺀다. 음성인식(faster-whisper)과 번역
(NLLB) 모두 CTranslate2 위에서 돌아가고 CTranslate2 는 torch 를 요구하지 (NLLB) 모두 CTranslate2 위에서 돌아가고 CTranslate2 는 torch 를 요구하지
않기 때문에, 1~3티어는 그대로 다 동작한다. 4~5티어(Seed-X / Qwen3)만 않기 때문에, 1~3티어는 그대로 다 동작한다.
다만 NLLB 토크나이저는 transformers 의 AutoTokenizer 를 쓰므로 transformers
자체는 넣어야 한다. transformers 는 torch 가 없으면 "토크나이저/설정만 사용
가능" 모드로 뜨며, 우리가 쓰는 용도가 정확히 그것이다 (실측 확인). 4~5티어(Seed-X / Qwen3)만
빠지며, 앱이 '모델' 화면에서 그 사실을 표시한다. 빠지며, 앱이 '모델' 화면에서 그 사실을 표시한다.
빌드: 빌드:
@@ -45,12 +49,16 @@ hiddenimports = [
"faster_whisper", "faster_whisper",
"tokenizers", "tokenizers",
"sentencepiece", "sentencepiece",
"transformers",
"transformers.models.nllb",
"huggingface_hub", "huggingface_hub",
] ]
# 넣으면 용량만 키우고 쓰지 않는 것들. torch 계열은 의도적으로 제외한다. # 넣으면 용량만 키우고 쓰지 않는 것들. torch 계열은 의도적으로 제외한다.
excludes = [ excludes = [
"torch", "torchvision", "torchaudio", "transformers", "peft", # transformers 는 제외하지 않는다. NLLB 토크나이저(AutoTokenizer)가 필요하고,
# torch 없이 토크나이저 전용으로 동작하는 것을 실제로 확인했다.
"torch", "torchvision", "torchaudio", "peft",
"accelerate", "datasets", "bitsandbytes", "autoawq", "accelerate", "datasets", "bitsandbytes", "autoawq",
"matplotlib", "scipy", "pandas", "IPython", "notebook", "matplotlib", "scipy", "pandas", "IPython", "notebook",
"tkinter", "PySide6.QtWebEngineCore", "PySide6.Qt3DCore", "tkinter", "PySide6.QtWebEngineCore", "PySide6.Qt3DCore",

View File

@@ -13,6 +13,8 @@ faster-whisper>=1.0
ctranslate2>=4.4 ctranslate2>=4.4
tokenizers>=0.19 tokenizers>=0.19
sentencepiece>=0.2 sentencepiece>=0.2
# NLLB 토크나이저용. torch 없이 토크나이저 전용 모드로 동작한다.
transformers>=4.44
huggingface-hub>=0.24 huggingface-hub>=0.24
# Windows 오디오 캡처 # Windows 오디오 캡처

View File

@@ -28,7 +28,12 @@ from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager from ..models import Glossary, ModelManager
from ..models.manager import apply_vram_limit from ..models.manager import apply_vram_limit
from ..models.packs import build_glossary from ..models.packs import build_glossary
from ..models.speech_level import SpeechLevel, apply_speech_level, detect_politeness from ..models.speech_level import (
SpeechLevel,
apply_speech_level,
detect_politeness,
fix_particles,
)
from .events import EngineState, EngineStatus, TranslationLine from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -294,6 +299,8 @@ class TranslationEngine:
translated = apply_speech_level( translated = apply_speech_level(
translated, target, self._speech_level(), politeness translated, target, self._speech_level(), politeness
) )
if target == "ko":
translated = fix_particles(translated)
mt_ms = int((time.perf_counter() - t1) * 1000) mt_ms = int((time.perf_counter() - t1) * 1000)
self._last_final_text = transcript.text self._last_final_text = transcript.text

View File

@@ -66,7 +66,8 @@
"ko": "내 배너 챙겨", "ko": "내 배너 챙겨",
"ja": "バナー取って", "ja": "バナー取って",
"zh": "捡我旗帜" "zh": "捡我旗帜"
} },
"whole_only": true
}, },
{ {
"source": "respawn beacon", "source": "respawn beacon",
@@ -90,7 +91,8 @@
"ko": "눕혔다", "ko": "눕혔다",
"ja": "ダウンさせた", "ja": "ダウンさせた",
"zh": "打倒了" "zh": "打倒了"
} },
"whole_only": true
}, },
{ {
"source": "thirsting", "source": "thirsting",
@@ -106,7 +108,8 @@
"ko": "피니셔", "ko": "피니셔",
"ja": "フィニッシャー", "ja": "フィニッシャー",
"zh": "处决" "zh": "处决"
} },
"whole_only": true
}, },
{ {
"source": "knockdown shield", "source": "knockdown shield",
@@ -306,7 +309,8 @@
"ko": "밀어", "ko": "밀어",
"ja": "詰めよう", "ja": "詰めよう",
"zh": "推他们" "zh": "推他们"
} },
"whole_only": true
}, },
{ {
"source": "back off", "source": "back off",
@@ -314,7 +318,8 @@
"ko": "빠져", "ko": "빠져",
"ja": "引こう", "ja": "引こう",
"zh": "撤" "zh": "撤"
} },
"whole_only": true
}, },
{ {
"source": "high ground", "source": "high ground",
@@ -338,7 +343,8 @@
"ko": "핑 찍어", "ko": "핑 찍어",
"ja": "ピンして", "ja": "ピンして",
"zh": "标记一下" "zh": "标记一下"
} },
"whole_only": true
}, },
{ {
"source": "squad wiped", "source": "squad wiped",

View File

@@ -42,7 +42,8 @@
"ko": "엄호해줘", "ko": "엄호해줘",
"ja": "援護して", "ja": "援護して",
"zh": "掩护我" "zh": "掩护我"
} },
"whole_only": true
}, },
{ {
"source": "covering", "source": "covering",
@@ -58,7 +59,8 @@
"ko": "밀자", "ko": "밀자",
"ja": "押そう", "ja": "押そう",
"zh": "推进" "zh": "推进"
} },
"whole_only": true
}, },
{ {
"source": "fall back", "source": "fall back",
@@ -66,7 +68,8 @@
"ko": "후퇴", "ko": "후퇴",
"ja": "後退", "ja": "後退",
"zh": "撤退" "zh": "撤退"
} },
"whole_only": true
}, },
{ {
"source": "hold", "source": "hold",
@@ -106,7 +109,8 @@
"ko": "살려줘", "ko": "살려줘",
"ja": "蘇生して", "ja": "蘇生して",
"zh": "救我" "zh": "救我"
} },
"whole_only": true
}, },
{ {
"source": "heal up", "source": "heal up",
@@ -114,7 +118,8 @@
"ko": "회복하자", "ko": "회복하자",
"ja": "回復しよう", "ja": "回復しよう",
"zh": "回血" "zh": "回血"
} },
"whole_only": true
}, },
{ {
"source": "loot", "source": "loot",
@@ -186,7 +191,8 @@
"ko": "뒤에 있다", "ko": "뒤에 있다",
"ja": "後ろにいる", "ja": "後ろにいる",
"zh": "背后有人" "zh": "背后有人"
} },
"whole_only": true
}, },
{ {
"source": "on me", "source": "on me",
@@ -354,7 +360,8 @@
"ko": "잘 했다", "ko": "잘 했다",
"ja": "GG", "ja": "GG",
"zh": "打得好" "zh": "打得好"
} },
"whole_only": true
}, },
{ {
"source": "nice shot", "source": "nice shot",
@@ -362,7 +369,8 @@
"ko": "잘 쐈다", "ko": "잘 쐈다",
"ja": "ナイスショット", "ja": "ナイスショット",
"zh": "好枪" "zh": "好枪"
} },
"whole_only": true
}, },
{ {
"source": "my bad", "source": "my bad",
@@ -370,7 +378,8 @@
"ko": "내 실수", "ko": "내 실수",
"ja": "ごめん", "ja": "ごめん",
"zh": "我的错" "zh": "我的错"
} },
"whole_only": true
} }
] ]
} }

View File

@@ -170,7 +170,8 @@
"ko": "모이자", "ko": "모이자",
"ja": "集合", "ja": "集合",
"zh": "集合" "zh": "集合"
} },
"whole_only": true
}, },
{ {
"source": "group up", "source": "group up",
@@ -178,7 +179,8 @@
"ko": "모이자", "ko": "모이자",
"ja": "集合", "ja": "集合",
"zh": "集合" "zh": "集合"
} },
"whole_only": true
}, },
{ {
"source": "stagger", "source": "stagger",
@@ -378,7 +380,8 @@
"ko": "뒤에 있다", "ko": "뒤에 있다",
"ja": "後ろだ", "ja": "後ろだ",
"zh": "背后有人" "zh": "背后有人"
} },
"whole_only": true
}, },
{ {
"source": "falling back", "source": "falling back",
@@ -386,7 +389,8 @@
"ko": "후퇴한다", "ko": "후퇴한다",
"ja": "後退する", "ja": "後退する",
"zh": "撤退" "zh": "撤退"
} },
"whole_only": true
}, },
{ {
"source": "push", "source": "push",
@@ -394,7 +398,8 @@
"ko": "밀자", "ko": "밀자",
"ja": "押そう", "ja": "押そう",
"zh": "推进" "zh": "推进"
} },
"whole_only": true
} }
] ]
} }

View File

@@ -18,7 +18,8 @@
"ko": "이겼닭! 오늘 저녁은 치킨이닭!", "ko": "이겼닭! 오늘 저녁은 치킨이닭!",
"ja": "ドン勝つ!", "ja": "ドン勝つ!",
"zh": "大吉大利,今晚吃鸡" "zh": "大吉大利,今晚吃鸡"
} },
"whole_only": true
}, },
{ {
"source": "blue zone", "source": "blue zone",
@@ -98,7 +99,8 @@
"ko": "어디 내릴까", "ko": "어디 내릴까",
"ja": "どこ降りる", "ja": "どこ降りる",
"zh": "跳哪里" "zh": "跳哪里"
} },
"whole_only": true
}, },
{ {
"source": "loot", "source": "loot",
@@ -146,7 +148,8 @@
"ko": "어부 당했다", "ko": "어부 당했다",
"ja": "漁夫られた", "ja": "漁夫られた",
"zh": "被第三方了" "zh": "被第三方了"
} },
"whole_only": true
}, },
{ {
"source": "squad", "source": "squad",
@@ -178,7 +181,8 @@
"ko": "눕혔다", "ko": "눕혔다",
"ja": "ダウンさせた", "ja": "ダウンさせた",
"zh": "打倒了" "zh": "打倒了"
} },
"whole_only": true
}, },
{ {
"source": "knocked down", "source": "knocked down",
@@ -194,7 +198,8 @@
"ko": "마무리해", "ko": "마무리해",
"ja": "とどめ刺して", "ja": "とどめ刺して",
"zh": "补掉他" "zh": "补掉他"
} },
"whole_only": true
}, },
{ {
"source": "revive", "source": "revive",
@@ -218,7 +223,8 @@
"ko": "살려줘", "ko": "살려줘",
"ja": "起こして", "ja": "起こして",
"zh": "救我" "zh": "救我"
} },
"whole_only": true
}, },
{ {
"source": "med kit", "source": "med kit",
@@ -370,7 +376,8 @@
"ko": "탄 필요해", "ko": "탄 필요해",
"ja": "弾ちょうだい", "ja": "弾ちょうだい",
"zh": "要子弹" "zh": "要子弹"
} },
"whole_only": true
}, },
{ {
"source": "frag grenade", "source": "frag grenade",
@@ -418,7 +425,8 @@
"ko": "엄폐해", "ko": "엄폐해",
"ja": "隠れて", "ja": "隠れて",
"zh": "找掩体" "zh": "找掩体"
} },
"whole_only": true
}, },
{ {
"source": "cover", "source": "cover",
@@ -466,7 +474,8 @@
"ko": "차 타", "ko": "차 타",
"ja": "車乗って", "ja": "車乗って",
"zh": "上车" "zh": "上车"
} },
"whole_only": true
}, },
{ {
"source": "fuel", "source": "fuel",
@@ -490,7 +499,8 @@
"ko": "발소리 들린다", "ko": "발소리 들린다",
"ja": "足音する", "ja": "足音する",
"zh": "听到脚步" "zh": "听到脚步"
} },
"whole_only": true
}, },
{ {
"source": "sniper", "source": "sniper",

View File

@@ -130,7 +130,8 @@
"ko": "설치됐다", "ko": "설치됐다",
"ja": "設置された", "ja": "設置された",
"zh": "已安装" "zh": "已安装"
} },
"whole_only": true
}, },
{ {
"source": "defuse", "source": "defuse",
@@ -154,7 +155,8 @@
"ko": "보강하다", "ko": "보강하다",
"ja": "補強する", "ja": "補強する",
"zh": "加固" "zh": "加固"
} },
"whole_only": true
}, },
{ {
"source": "hard breach", "source": "hard breach",
@@ -234,7 +236,8 @@
"ko": "런아웃", "ko": "런아웃",
"ja": "ランアウト", "ja": "ランアウト",
"zh": "冲出" "zh": "冲出"
} },
"whole_only": true
}, },
{ {
"source": "spawn peek", "source": "spawn peek",
@@ -346,7 +349,8 @@
"ko": "카메라 깨줘", "ko": "카메라 깨줘",
"ja": "カメラ壊して", "ja": "カメラ壊して",
"zh": "打掉摄像头" "zh": "打掉摄像头"
} },
"whole_only": true
}, },
{ {
"source": "prefire", "source": "prefire",
@@ -474,7 +478,8 @@
"ko": "측면 봐줘", "ko": "측면 봐줘",
"ja": "フランク見て", "ja": "フランク見て",
"zh": "看侧面" "zh": "看侧面"
} },
"whole_only": true
}, },
{ {
"source": "above you", "source": "above you",
@@ -482,7 +487,8 @@
"ko": "위에 있다", "ko": "위에 있다",
"ja": "上にいる", "ja": "上にいる",
"zh": "在你上面" "zh": "在你上面"
} },
"whole_only": true
}, },
{ {
"source": "below you", "source": "below you",
@@ -490,7 +496,8 @@
"ko": "아래에 있다", "ko": "아래에 있다",
"ja": "下にいる", "ja": "下にいる",
"zh": "在你下面" "zh": "在你下面"
} },
"whole_only": true
}, },
{ {
"source": "basement", "source": "basement",

View File

@@ -42,7 +42,8 @@
"ko": "후원하다", "ko": "후원하다",
"ja": "投げ銭する", "ja": "投げ銭する",
"zh": "打赏" "zh": "打赏"
} },
"whole_only": true
}, },
{ {
"source": "sub", "source": "sub",
@@ -210,7 +211,8 @@
"ko": "던지다", "ko": "던지다",
"ja": "スローする", "ja": "スローする",
"zh": "送" "zh": "送"
} },
"whole_only": true
}, },
{ {
"source": "griefing", "source": "griefing",
@@ -234,7 +236,8 @@
"ko": "미쳤다", "ko": "미쳤다",
"ja": "ヤバい", "ja": "ヤバい",
"zh": "太强了" "zh": "太强了"
} },
"whole_only": true
}, },
{ {
"source": "insane", "source": "insane",
@@ -258,7 +261,8 @@
"ko": "가자", "ko": "가자",
"ja": "行くぞ", "ja": "行くぞ",
"zh": "冲" "zh": "冲"
} },
"whole_only": true
}, },
{ {
"source": "no way", "source": "no way",
@@ -266,7 +270,8 @@
"ko": "말도 안 돼", "ko": "말도 안 돼",
"ja": "ありえない", "ja": "ありえない",
"zh": "不可能吧" "zh": "不可能吧"
} },
"whole_only": true
}, },
{ {
"source": "give me a second", "source": "give me a second",
@@ -274,7 +279,8 @@
"ko": "잠깐만", "ko": "잠깐만",
"ja": "ちょっと待って", "ja": "ちょっと待って",
"zh": "等一下" "zh": "等一下"
} },
"whole_only": true
}, },
{ {
"source": "hold on", "source": "hold on",
@@ -282,7 +288,8 @@
"ko": "잠깐", "ko": "잠깐",
"ja": "待って", "ja": "待って",
"zh": "等等" "zh": "等等"
} },
"whole_only": true
}, },
{ {
"source": "be right back", "source": "be right back",
@@ -290,7 +297,8 @@
"ko": "금방 올게", "ko": "금방 올게",
"ja": "すぐ戻る", "ja": "すぐ戻る",
"zh": "马上回来" "zh": "马上回来"
} },
"whole_only": true
}, },
{ {
"source": "thanks for the sub", "source": "thanks for the sub",
@@ -298,7 +306,8 @@
"ko": "구독 고마워", "ko": "구독 고마워",
"ja": "サブありがとう", "ja": "サブありがとう",
"zh": "谢谢订阅" "zh": "谢谢订阅"
} },
"whole_only": true
}, },
{ {
"source": "welcome back", "source": "welcome back",
@@ -306,7 +315,8 @@
"ko": "다시 왔네", "ko": "다시 왔네",
"ja": "おかえり", "ja": "おかえり",
"zh": "欢迎回来" "zh": "欢迎回来"
} },
"whole_only": true
} }
] ]
} }

View File

@@ -26,7 +26,8 @@
"ko": "설치됐다", "ko": "설치됐다",
"ja": "設置された", "ja": "設置された",
"zh": "已安装" "zh": "已安装"
} },
"whole_only": true
}, },
{ {
"source": "defuse", "source": "defuse",
@@ -410,7 +411,8 @@
"ko": "봤다", "ko": "봤다",
"ja": "発見", "ja": "発見",
"zh": "看到了" "zh": "看到了"
} },
"whole_only": true
}, },
{ {
"source": "one down", "source": "one down",

View File

@@ -122,7 +122,8 @@
"ko": "해킹됐다", "ko": "해킹됐다",
"ja": "ハックされた", "ja": "ハックされた",
"zh": "被黑了" "zh": "被黑了"
} },
"whole_only": true
}, },
{ {
"source": "remote hack", "source": "remote hack",
@@ -314,7 +315,8 @@
"ko": "발각됐다", "ko": "발각됐다",
"ja": "見つかった", "ja": "見つかった",
"zh": "被发现了" "zh": "被发现了"
} },
"whole_only": true
}, },
{ {
"source": "stay hidden", "source": "stay hidden",
@@ -322,7 +324,8 @@
"ko": "숨어 있어", "ko": "숨어 있어",
"ja": "隠れてろ", "ja": "隠れてろ",
"zh": "别暴露" "zh": "别暴露"
} },
"whole_only": true
}, },
{ {
"source": "take down", "source": "take down",
@@ -330,7 +333,8 @@
"ko": "제압", "ko": "제압",
"ja": "テイクダウン", "ja": "テイクダウン",
"zh": "制服" "zh": "制服"
} },
"whole_only": true
}, },
{ {
"source": "non lethal", "source": "non lethal",
@@ -346,7 +350,8 @@
"ko": "살상", "ko": "살상",
"ja": "殺傷", "ja": "殺傷",
"zh": "致命" "zh": "致命"
} },
"whole_only": true
}, },
{ {
"source": "heat level", "source": "heat level",
@@ -370,7 +375,8 @@
"ko": "경찰 따돌려", "ko": "경찰 따돌려",
"ja": "警察撒いて", "ja": "警察撒いて",
"zh": "甩掉警察" "zh": "甩掉警察"
} },
"whole_only": true
}, },
{ {
"source": "traffic light", "source": "traffic light",
@@ -474,7 +480,8 @@
"ko": "도시가 지켜보고 있다", "ko": "도시가 지켜보고 있다",
"ja": "街が見ている", "ja": "街が見ている",
"zh": "城市在注视" "zh": "城市在注视"
} },
"whole_only": true
} }
] ]
} }

View File

@@ -72,4 +72,22 @@ def test_add_replaces_existing_entry():
def test_restore_ignores_out_of_range_placeholder(): 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() cfg = AppConfig()
merged = build_glossary(Glossary(), cfg.glossary.enabled_packs) merged = build_glossary(Glossary(), cfg.glossary.enabled_packs)
# 'hacked' 는 서술어라 문장 중간에서는 치환하지 않는다 (whole_only).
_, repl = merged.protect("DedSec hacked the ctOS server", "ko") _, 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 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") == ( 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를 해킹"