From bede94ee482dc7701540c29294962b19815e9799 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 23 Sep 2026 01:20:42 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8F=AC=ED=84=B0=EB=B8=94=EC=97=90=20t?= =?UTF-8?q?ransformers=20=ED=8F=AC=ED=95=A8=20+=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EB=A1=9C=20=EB=93=9C=EB=9F=AC=EB=82=9C=20?= =?UTF-8?q?=EC=9A=A9=EC=96=B4=EC=A7=91=20=EA=B2=B0=ED=95=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 지적(포터블에서 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 --- README.md | 4 +- docs/ARCHITECTURE.md | 19 +++ packaging/livesub.spec | 12 +- packaging/requirements-portable.txt | 2 + src/livesub/core/engine.py | 9 +- src/livesub/resources/glossaries/apex.json | 18 ++- .../resources/glossaries/fps-common.json | 27 ++-- .../resources/glossaries/overwatch.json | 15 +- src/livesub/resources/glossaries/pubg.json | 30 ++-- src/livesub/resources/glossaries/r6siege.json | 21 ++- .../resources/glossaries/streaming.json | 30 ++-- .../resources/glossaries/valorant.json | 6 +- .../resources/glossaries/watchdogs.json | 21 ++- tests/test_glossary.py | 20 ++- tests/test_packs.py | 21 ++- tests/test_portable.py | 141 ++++++++++++++++++ tests/test_prompts.py | 2 +- tests/test_speech_level.py | 36 +++++ 18 files changed, 370 insertions(+), 64 deletions(-) create mode 100644 tests/test_portable.py diff --git a/README.md b/README.md index 807a682..d07608f 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,11 @@ Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다 | | 포터블 (권장) | 일반 설치 | |---|---|---| | 준비물 | `LiveSub.exe` 하나 | Python + PyTorch | -| 크기 | 약 500MB | 약 3GB | +| 크기 | 약 500~700MB | 약 3GB | | 쓸 수 있는 티어 | 1~3 | 1~5 전부 | | 번역 품질 | NLLB | NLLB + Seed-X / Qwen3 | -포터블은 PyTorch를 빼서 만듭니다. PyTorch+CUDA만 2.5GB라 한 파일로 묶으면 +포터블은 PyTorch를 빼서 만듭니다 (토크나이저용 `transformers`는 포함). PyTorch+CUDA만 2.5GB라 한 파일로 묶으면 **실행할 때마다** 그걸 임시폴더에 푸느라 1분 넘게 걸리기 때문입니다. 음성인식도 번역도 CTranslate2 위에서 돌아가 PyTorch가 필요 없으므로, 1~3티어는 포터블에서 그대로 다 됩니다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ebbce6f..3b45541 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,6 +106,25 @@ WASAPI의 `ActivateAudioInterfaceAsync` + `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_L 자기장"이 된다. `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에서 떼어냈다 `placement.py` 는 `ScreenInfo` 라는 순수 데이터만 받아 좌표를 계산한다. 덕분에 diff --git a/packaging/livesub.spec b/packaging/livesub.spec index 58f8fc3..5d26e2f 100644 --- a/packaging/livesub.spec +++ b/packaging/livesub.spec @@ -7,7 +7,11 @@ 그래서 포터블 빌드는 torch 를 뺀다. 음성인식(faster-whisper)과 번역 (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", "tokenizers", "sentencepiece", + "transformers", + "transformers.models.nllb", "huggingface_hub", ] # 넣으면 용량만 키우고 쓰지 않는 것들. torch 계열은 의도적으로 제외한다. excludes = [ - "torch", "torchvision", "torchaudio", "transformers", "peft", + # transformers 는 제외하지 않는다. NLLB 토크나이저(AutoTokenizer)가 필요하고, + # torch 없이 토크나이저 전용으로 동작하는 것을 실제로 확인했다. + "torch", "torchvision", "torchaudio", "peft", "accelerate", "datasets", "bitsandbytes", "autoawq", "matplotlib", "scipy", "pandas", "IPython", "notebook", "tkinter", "PySide6.QtWebEngineCore", "PySide6.Qt3DCore", diff --git a/packaging/requirements-portable.txt b/packaging/requirements-portable.txt index 069bdd1..0c3d08a 100644 --- a/packaging/requirements-portable.txt +++ b/packaging/requirements-portable.txt @@ -13,6 +13,8 @@ faster-whisper>=1.0 ctranslate2>=4.4 tokenizers>=0.19 sentencepiece>=0.2 +# NLLB 토크나이저용. torch 없이 토크나이저 전용 모드로 동작한다. +transformers>=4.44 huggingface-hub>=0.24 # Windows 오디오 캡처 diff --git a/src/livesub/core/engine.py b/src/livesub/core/engine.py index a87371a..0418502 100644 --- a/src/livesub/core/engine.py +++ b/src/livesub/core/engine.py @@ -28,7 +28,12 @@ from ..constants import LANGUAGE_CODES, user_data_dir from ..models import Glossary, ModelManager from ..models.manager import apply_vram_limit 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 log = logging.getLogger(__name__) @@ -294,6 +299,8 @@ class TranslationEngine: translated = apply_speech_level( translated, target, self._speech_level(), politeness ) + if target == "ko": + translated = fix_particles(translated) mt_ms = int((time.perf_counter() - t1) * 1000) self._last_final_text = transcript.text diff --git a/src/livesub/resources/glossaries/apex.json b/src/livesub/resources/glossaries/apex.json index 7f03fc5..756b268 100644 --- a/src/livesub/resources/glossaries/apex.json +++ b/src/livesub/resources/glossaries/apex.json @@ -66,7 +66,8 @@ "ko": "내 배너 챙겨", "ja": "バナー取って", "zh": "捡我旗帜" - } + }, + "whole_only": true }, { "source": "respawn beacon", @@ -90,7 +91,8 @@ "ko": "눕혔다", "ja": "ダウンさせた", "zh": "打倒了" - } + }, + "whole_only": true }, { "source": "thirsting", @@ -106,7 +108,8 @@ "ko": "피니셔", "ja": "フィニッシャー", "zh": "处决" - } + }, + "whole_only": true }, { "source": "knockdown shield", @@ -306,7 +309,8 @@ "ko": "밀어", "ja": "詰めよう", "zh": "推他们" - } + }, + "whole_only": true }, { "source": "back off", @@ -314,7 +318,8 @@ "ko": "빠져", "ja": "引こう", "zh": "撤" - } + }, + "whole_only": true }, { "source": "high ground", @@ -338,7 +343,8 @@ "ko": "핑 찍어", "ja": "ピンして", "zh": "标记一下" - } + }, + "whole_only": true }, { "source": "squad wiped", diff --git a/src/livesub/resources/glossaries/fps-common.json b/src/livesub/resources/glossaries/fps-common.json index e070488..9520f53 100644 --- a/src/livesub/resources/glossaries/fps-common.json +++ b/src/livesub/resources/glossaries/fps-common.json @@ -42,7 +42,8 @@ "ko": "엄호해줘", "ja": "援護して", "zh": "掩护我" - } + }, + "whole_only": true }, { "source": "covering", @@ -58,7 +59,8 @@ "ko": "밀자", "ja": "押そう", "zh": "推进" - } + }, + "whole_only": true }, { "source": "fall back", @@ -66,7 +68,8 @@ "ko": "후퇴", "ja": "後退", "zh": "撤退" - } + }, + "whole_only": true }, { "source": "hold", @@ -106,7 +109,8 @@ "ko": "살려줘", "ja": "蘇生して", "zh": "救我" - } + }, + "whole_only": true }, { "source": "heal up", @@ -114,7 +118,8 @@ "ko": "회복하자", "ja": "回復しよう", "zh": "回血" - } + }, + "whole_only": true }, { "source": "loot", @@ -186,7 +191,8 @@ "ko": "뒤에 있다", "ja": "後ろにいる", "zh": "背后有人" - } + }, + "whole_only": true }, { "source": "on me", @@ -354,7 +360,8 @@ "ko": "잘 했다", "ja": "GG", "zh": "打得好" - } + }, + "whole_only": true }, { "source": "nice shot", @@ -362,7 +369,8 @@ "ko": "잘 쐈다", "ja": "ナイスショット", "zh": "好枪" - } + }, + "whole_only": true }, { "source": "my bad", @@ -370,7 +378,8 @@ "ko": "내 실수", "ja": "ごめん", "zh": "我的错" - } + }, + "whole_only": true } ] } diff --git a/src/livesub/resources/glossaries/overwatch.json b/src/livesub/resources/glossaries/overwatch.json index 9770b14..513b650 100644 --- a/src/livesub/resources/glossaries/overwatch.json +++ b/src/livesub/resources/glossaries/overwatch.json @@ -170,7 +170,8 @@ "ko": "모이자", "ja": "集合", "zh": "集合" - } + }, + "whole_only": true }, { "source": "group up", @@ -178,7 +179,8 @@ "ko": "모이자", "ja": "集合", "zh": "集合" - } + }, + "whole_only": true }, { "source": "stagger", @@ -378,7 +380,8 @@ "ko": "뒤에 있다", "ja": "後ろだ", "zh": "背后有人" - } + }, + "whole_only": true }, { "source": "falling back", @@ -386,7 +389,8 @@ "ko": "후퇴한다", "ja": "後退する", "zh": "撤退" - } + }, + "whole_only": true }, { "source": "push", @@ -394,7 +398,8 @@ "ko": "밀자", "ja": "押そう", "zh": "推进" - } + }, + "whole_only": true } ] } diff --git a/src/livesub/resources/glossaries/pubg.json b/src/livesub/resources/glossaries/pubg.json index e7789d2..20f1141 100644 --- a/src/livesub/resources/glossaries/pubg.json +++ b/src/livesub/resources/glossaries/pubg.json @@ -18,7 +18,8 @@ "ko": "이겼닭! 오늘 저녁은 치킨이닭!", "ja": "ドン勝つ!", "zh": "大吉大利,今晚吃鸡" - } + }, + "whole_only": true }, { "source": "blue zone", @@ -98,7 +99,8 @@ "ko": "어디 내릴까", "ja": "どこ降りる", "zh": "跳哪里" - } + }, + "whole_only": true }, { "source": "loot", @@ -146,7 +148,8 @@ "ko": "어부 당했다", "ja": "漁夫られた", "zh": "被第三方了" - } + }, + "whole_only": true }, { "source": "squad", @@ -178,7 +181,8 @@ "ko": "눕혔다", "ja": "ダウンさせた", "zh": "打倒了" - } + }, + "whole_only": true }, { "source": "knocked down", @@ -194,7 +198,8 @@ "ko": "마무리해", "ja": "とどめ刺して", "zh": "补掉他" - } + }, + "whole_only": true }, { "source": "revive", @@ -218,7 +223,8 @@ "ko": "살려줘", "ja": "起こして", "zh": "救我" - } + }, + "whole_only": true }, { "source": "med kit", @@ -370,7 +376,8 @@ "ko": "탄 필요해", "ja": "弾ちょうだい", "zh": "要子弹" - } + }, + "whole_only": true }, { "source": "frag grenade", @@ -418,7 +425,8 @@ "ko": "엄폐해", "ja": "隠れて", "zh": "找掩体" - } + }, + "whole_only": true }, { "source": "cover", @@ -466,7 +474,8 @@ "ko": "차 타", "ja": "車乗って", "zh": "上车" - } + }, + "whole_only": true }, { "source": "fuel", @@ -490,7 +499,8 @@ "ko": "발소리 들린다", "ja": "足音する", "zh": "听到脚步" - } + }, + "whole_only": true }, { "source": "sniper", diff --git a/src/livesub/resources/glossaries/r6siege.json b/src/livesub/resources/glossaries/r6siege.json index c2cae59..1532324 100644 --- a/src/livesub/resources/glossaries/r6siege.json +++ b/src/livesub/resources/glossaries/r6siege.json @@ -130,7 +130,8 @@ "ko": "설치됐다", "ja": "設置された", "zh": "已安装" - } + }, + "whole_only": true }, { "source": "defuse", @@ -154,7 +155,8 @@ "ko": "보강하다", "ja": "補強する", "zh": "加固" - } + }, + "whole_only": true }, { "source": "hard breach", @@ -234,7 +236,8 @@ "ko": "런아웃", "ja": "ランアウト", "zh": "冲出" - } + }, + "whole_only": true }, { "source": "spawn peek", @@ -346,7 +349,8 @@ "ko": "카메라 깨줘", "ja": "カメラ壊して", "zh": "打掉摄像头" - } + }, + "whole_only": true }, { "source": "prefire", @@ -474,7 +478,8 @@ "ko": "측면 봐줘", "ja": "フランク見て", "zh": "看侧面" - } + }, + "whole_only": true }, { "source": "above you", @@ -482,7 +487,8 @@ "ko": "위에 있다", "ja": "上にいる", "zh": "在你上面" - } + }, + "whole_only": true }, { "source": "below you", @@ -490,7 +496,8 @@ "ko": "아래에 있다", "ja": "下にいる", "zh": "在你下面" - } + }, + "whole_only": true }, { "source": "basement", diff --git a/src/livesub/resources/glossaries/streaming.json b/src/livesub/resources/glossaries/streaming.json index 15ecf51..d0f4094 100644 --- a/src/livesub/resources/glossaries/streaming.json +++ b/src/livesub/resources/glossaries/streaming.json @@ -42,7 +42,8 @@ "ko": "후원하다", "ja": "投げ銭する", "zh": "打赏" - } + }, + "whole_only": true }, { "source": "sub", @@ -210,7 +211,8 @@ "ko": "던지다", "ja": "スローする", "zh": "送" - } + }, + "whole_only": true }, { "source": "griefing", @@ -234,7 +236,8 @@ "ko": "미쳤다", "ja": "ヤバい", "zh": "太强了" - } + }, + "whole_only": true }, { "source": "insane", @@ -258,7 +261,8 @@ "ko": "가자", "ja": "行くぞ", "zh": "冲" - } + }, + "whole_only": true }, { "source": "no way", @@ -266,7 +270,8 @@ "ko": "말도 안 돼", "ja": "ありえない", "zh": "不可能吧" - } + }, + "whole_only": true }, { "source": "give me a second", @@ -274,7 +279,8 @@ "ko": "잠깐만", "ja": "ちょっと待って", "zh": "等一下" - } + }, + "whole_only": true }, { "source": "hold on", @@ -282,7 +288,8 @@ "ko": "잠깐", "ja": "待って", "zh": "等等" - } + }, + "whole_only": true }, { "source": "be right back", @@ -290,7 +297,8 @@ "ko": "금방 올게", "ja": "すぐ戻る", "zh": "马上回来" - } + }, + "whole_only": true }, { "source": "thanks for the sub", @@ -298,7 +306,8 @@ "ko": "구독 고마워", "ja": "サブありがとう", "zh": "谢谢订阅" - } + }, + "whole_only": true }, { "source": "welcome back", @@ -306,7 +315,8 @@ "ko": "다시 왔네", "ja": "おかえり", "zh": "欢迎回来" - } + }, + "whole_only": true } ] } diff --git a/src/livesub/resources/glossaries/valorant.json b/src/livesub/resources/glossaries/valorant.json index 36ffe30..3d6a98f 100644 --- a/src/livesub/resources/glossaries/valorant.json +++ b/src/livesub/resources/glossaries/valorant.json @@ -26,7 +26,8 @@ "ko": "설치됐다", "ja": "設置された", "zh": "已安装" - } + }, + "whole_only": true }, { "source": "defuse", @@ -410,7 +411,8 @@ "ko": "봤다", "ja": "発見", "zh": "看到了" - } + }, + "whole_only": true }, { "source": "one down", diff --git a/src/livesub/resources/glossaries/watchdogs.json b/src/livesub/resources/glossaries/watchdogs.json index e1ca969..577e47b 100644 --- a/src/livesub/resources/glossaries/watchdogs.json +++ b/src/livesub/resources/glossaries/watchdogs.json @@ -122,7 +122,8 @@ "ko": "해킹됐다", "ja": "ハックされた", "zh": "被黑了" - } + }, + "whole_only": true }, { "source": "remote hack", @@ -314,7 +315,8 @@ "ko": "발각됐다", "ja": "見つかった", "zh": "被发现了" - } + }, + "whole_only": true }, { "source": "stay hidden", @@ -322,7 +324,8 @@ "ko": "숨어 있어", "ja": "隠れてろ", "zh": "别暴露" - } + }, + "whole_only": true }, { "source": "take down", @@ -330,7 +333,8 @@ "ko": "제압", "ja": "テイクダウン", "zh": "制服" - } + }, + "whole_only": true }, { "source": "non lethal", @@ -346,7 +350,8 @@ "ko": "살상", "ja": "殺傷", "zh": "致命" - } + }, + "whole_only": true }, { "source": "heat level", @@ -370,7 +375,8 @@ "ko": "경찰 따돌려", "ja": "警察撒いて", "zh": "甩掉警察" - } + }, + "whole_only": true }, { "source": "traffic light", @@ -474,7 +480,8 @@ "ko": "도시가 지켜보고 있다", "ja": "街が見ている", "zh": "城市在注视" - } + }, + "whole_only": true } ] } diff --git a/tests/test_glossary.py b/tests/test_glossary.py index 3dc601c..b71a3a4 100644 --- a/tests/test_glossary.py +++ b/tests/test_glossary.py @@ -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#" diff --git a/tests/test_packs.py b/tests/test_packs.py index 3e8ab85..6236103 100644 --- a/tests/test_packs.py +++ b/tests/test_packs.py @@ -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 == ["엄호해줘"] # --- 용어가 조각나서 두 번 치환되지 않는지 ----------------------------------- diff --git a/tests/test_portable.py b/tests/test_portable.py new file mode 100644 index 0000000..dd65a64 --- /dev/null +++ b/tests/test_portable.py @@ -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 diff --git a/tests/test_prompts.py b/tests/test_prompts.py index a2d4ca6..c4e08fe 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -79,4 +79,4 @@ def test_glossary_survives_seedx_placeholder_path(): assert prompt.endswith(" ") # 모델이 플레이스홀더를 그대로 통과시켰다고 가정 - assert Glossary.restore("⟦0⟧로 밀어", repl) == "넥서스로 밀어" + assert Glossary.restore("#0#로 밀어", repl) == "넥서스로 밀어" diff --git a/tests/test_speech_level.py b/tests/test_speech_level.py index 4136b3b..c0c4468 100644 --- a/tests/test_speech_level.py +++ b/tests/test_speech_level.py @@ -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를 해킹"