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:
@@ -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티어는 포터블에서 그대로 다 됩니다.
|
||||||
|
|||||||
@@ -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` 라는 순수 데이터만 받아 좌표를 계산한다. 덕분에
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 오디오 캡처
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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#"
|
||||||
|
|||||||
@@ -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
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
|
||||||
@@ -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) == "넥서스로 밀어"
|
||||||
|
|||||||
@@ -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를 해킹"
|
||||||
|
|||||||
Reference in New Issue
Block a user