Spaces:
Running
Running
Init Space: HachimiMT zh-vi demo (CT2, chuẩn hóa xưng hô)
Browse files- .gitattributes +4 -0
- README.md +30 -7
- app.py +17 -0
- exports/hachimimt_vi_20260618_182426.txt +1 -0
- models/HachimiMT-60-zh-vi/config.json +36 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/config.json +10 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/generation_config.json +12 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/model.bin +3 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/shared_vocabulary.json +0 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/source.spm +3 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/target.spm +3 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/tokenizer_config.json +50 -0
- models/HachimiMT-60-zh-vi/ct2-int8_float32/vocab.json +0 -0
- models/HachimiMT-60-zh-vi/source.spm +3 -0
- models/HachimiMT-60-zh-vi/target.spm +3 -0
- models/HachimiMT-60-zh-vi/tokenizer_config.json +50 -0
- models/HachimiMT-60-zh-vi/vocab.json +0 -0
- requirements.txt +5 -0
- src/app.py +1404 -0
- src/assets/favicon.svg +18 -0
- src/chunker.py +27 -0
- src/gpu_setup.py +162 -0
- src/hardware.py +237 -0
- src/honorific_normalize.py +321 -0
- src/postprocess_policy.py +93 -0
- src/progress_tracker.py +47 -0
- src/pronoun_harmonizer_v9.py +906 -0
- src/text_preprocess.py +38 -0
- src/token_chunker.py +133 -0
- src/translator.py +833 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
models/HachimiMT-60-zh-vi/ct2-int8_float32/source.spm filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
models/HachimiMT-60-zh-vi/ct2-int8_float32/target.spm filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
models/HachimiMT-60-zh-vi/source.spm filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
models/HachimiMT-60-zh-vi/target.spm filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,13 +1,36 @@
|
|
| 1 |
---
|
| 2 |
-
title: HachimiMT
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: HachimiMT — Dịch Trung Việt
|
| 3 |
+
emoji: 📜
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.18.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
short_description: Dịch truyện Trung → Việt, chuẩn hóa xưng hô Hán-Việt
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# HachimiMT — Dịch Trung → Việt
|
| 14 |
+
|
| 15 |
+
Công cụ dịch truyện tiếng Trung sang tiếng Việt bằng các model MarianMT (CTranslate2 INT8):
|
| 16 |
+
|
| 17 |
+
- [HachimiMT-60-zh-vi](https://huggingface.co/ngocdang83/HachimiMT-60-zh-vi) (mặc định)
|
| 18 |
+
- [HachimiMT-30-zh-vi](https://huggingface.co/ngocdang83/HachimiMT-30-zh-vi) (nhẹ ~35 MB)
|
| 19 |
+
- [MoxhiMT-60](https://huggingface.co/DanVP/MoxhiMT-60) · [MoxhiMT-30](https://huggingface.co/DanVP/MoxhiMT-30)
|
| 20 |
+
|
| 21 |
+
## Tính năng
|
| 22 |
+
|
| 23 |
+
- Dịch văn bản trực tiếp + **đối chiếu song song** theo từng câu/đoạn, hoặc dịch file `.txt`.
|
| 24 |
+
- **Chọn model** (HachimiMT / MoxhiMT, bản 60 hoặc 30) — model tự tải từ Hugging Face khi
|
| 25 |
+
chọn lần đầu (lazy), chạy CPU.
|
| 26 |
+
- **Chuẩn hóa chữ Hán** phồn → giản trước khi dịch (model train trên giản thể).
|
| 27 |
+
- **Tuỳ chọn chuẩn hóa xưng hô** (mục nâng cao, mặc định tắt) — ép xưng hô về Hán-Việt theo
|
| 28 |
+
từ tường minh trong nguồn:
|
| 29 |
+
- **Thân tộc**: `chị → tỷ`, `anh trai → ca ca`, `chị em → tỷ muội`… khi nguồn có 姐姐/哥哥/姐妹…
|
| 30 |
+
- **Đại từ**: `cậu → ngươi`, `cô ấy → nàng`, `tôi → ta` — chỉ áp ở văn cổ trang/tu tiên.
|
| 31 |
+
- **Ổn định ngôi hiện đại**: chỉnh ngôi theo ngữ cảnh (thầy/em, mẹ/con…) cho truyện hiện đại.
|
| 32 |
+
|
| 33 |
+
> Space chạy **CPU** (CTranslate2 INT8). Văn bản dài sẽ chậm hơn máy có GPU; chia **theo câu**
|
| 34 |
+
> giúp giảm trôi tên riêng.
|
| 35 |
+
|
| 36 |
+
Mã nguồn: <https://github.com/ngocdang8311/qt2>
|
app.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entrypoint cho HF Space — gọi app trong src/.
|
| 2 |
+
|
| 3 |
+
HF Space chạy file app.py ở root. App thật nằm trong src/ (dùng import phẳng
|
| 4 |
+
như `import translator`), nên thêm src/ vào sys.path rồi gọi main().
|
| 5 |
+
Phát hiện Space qua SPACE_ID (đã xử lý trong src/app.py: ẩn nút GPU/torch, bỏ
|
| 6 |
+
PID file, để HF tự lo host/port).
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
SRC = Path(__file__).resolve().parent / "src"
|
| 12 |
+
sys.path.insert(0, str(SRC))
|
| 13 |
+
|
| 14 |
+
import app as _app # noqa: E402
|
| 15 |
+
|
| 16 |
+
if __name__ == "__main__":
|
| 17 |
+
_app.main()
|
exports/hachimimt_vi_20260618_182426.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Tỷ tỷ nói với ngươi, các nàng là tỷ muội.
|
models/HachimiMT-60-zh-vi/config.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"activation_dropout": 0.0,
|
| 3 |
+
"activation_function": "swish",
|
| 4 |
+
"architectures": [
|
| 5 |
+
"MarianMTModel"
|
| 6 |
+
],
|
| 7 |
+
"attention_dropout": 0.1,
|
| 8 |
+
"bos_token_id": 1,
|
| 9 |
+
"d_model": 512,
|
| 10 |
+
"decoder_attention_heads": 8,
|
| 11 |
+
"decoder_ffn_dim": 3072,
|
| 12 |
+
"decoder_layerdrop": 0.0,
|
| 13 |
+
"decoder_layers": 2,
|
| 14 |
+
"decoder_start_token_id": 0,
|
| 15 |
+
"decoder_vocab_size": 24000,
|
| 16 |
+
"dropout": 0.1,
|
| 17 |
+
"dtype": "float32",
|
| 18 |
+
"encoder_attention_heads": 8,
|
| 19 |
+
"encoder_ffn_dim": 3072,
|
| 20 |
+
"encoder_layerdrop": 0.0,
|
| 21 |
+
"encoder_layers": 8,
|
| 22 |
+
"eos_token_id": 2,
|
| 23 |
+
"forced_eos_token_id": 2,
|
| 24 |
+
"init_std": 0.02,
|
| 25 |
+
"is_decoder": false,
|
| 26 |
+
"is_encoder_decoder": true,
|
| 27 |
+
"max_position_embeddings": 512,
|
| 28 |
+
"model_type": "marian",
|
| 29 |
+
"pad_token_id": 0,
|
| 30 |
+
"scale_embedding": true,
|
| 31 |
+
"share_encoder_decoder_embeddings": true,
|
| 32 |
+
"tie_word_embeddings": true,
|
| 33 |
+
"transformers_version": "5.9.0",
|
| 34 |
+
"use_cache": true,
|
| 35 |
+
"vocab_size": 24000
|
| 36 |
+
}
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/config.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"add_source_bos": false,
|
| 3 |
+
"add_source_eos": false,
|
| 4 |
+
"bos_token": "<s>",
|
| 5 |
+
"decoder_start_token": "<pad>",
|
| 6 |
+
"eos_token": "</s>",
|
| 7 |
+
"layer_norm_epsilon": null,
|
| 8 |
+
"multi_query_attention": false,
|
| 9 |
+
"unk_token": "<unk>"
|
| 10 |
+
}
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/generation_config.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_from_model_config": true,
|
| 3 |
+
"bos_token_id": 1,
|
| 4 |
+
"decoder_start_token_id": 0,
|
| 5 |
+
"eos_token_id": 2,
|
| 6 |
+
"forced_eos_token_id": 2,
|
| 7 |
+
"output_attentions": false,
|
| 8 |
+
"output_hidden_states": false,
|
| 9 |
+
"pad_token_id": 0,
|
| 10 |
+
"transformers_version": "5.9.0",
|
| 11 |
+
"use_cache": true
|
| 12 |
+
}
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/model.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ed9e9ec52051916be15ebedd9aff71bcb7a30300ee8d2fc2ec818670d222a881
|
| 3 |
+
size 58057231
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/shared_vocabulary.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/source.spm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d1557335c5892d2f33c6a4864f3ae598b235f705a713e32ca020b9fadb99a99e
|
| 3 |
+
size 606202
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/target.spm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d1557335c5892d2f33c6a4864f3ae598b235f705a713e32ca020b9fadb99a99e
|
| 3 |
+
size 606202
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/tokenizer_config.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"added_tokens_decoder": {
|
| 3 |
+
"0": {
|
| 4 |
+
"content": "<pad>",
|
| 5 |
+
"lstrip": false,
|
| 6 |
+
"normalized": false,
|
| 7 |
+
"rstrip": false,
|
| 8 |
+
"single_word": false,
|
| 9 |
+
"special": true
|
| 10 |
+
},
|
| 11 |
+
"1": {
|
| 12 |
+
"content": "<s>",
|
| 13 |
+
"lstrip": false,
|
| 14 |
+
"normalized": false,
|
| 15 |
+
"rstrip": false,
|
| 16 |
+
"single_word": false,
|
| 17 |
+
"special": true
|
| 18 |
+
},
|
| 19 |
+
"2": {
|
| 20 |
+
"content": "</s>",
|
| 21 |
+
"lstrip": false,
|
| 22 |
+
"normalized": false,
|
| 23 |
+
"rstrip": false,
|
| 24 |
+
"single_word": false,
|
| 25 |
+
"special": true
|
| 26 |
+
},
|
| 27 |
+
"3": {
|
| 28 |
+
"content": "<unk>",
|
| 29 |
+
"lstrip": false,
|
| 30 |
+
"normalized": false,
|
| 31 |
+
"rstrip": false,
|
| 32 |
+
"single_word": false,
|
| 33 |
+
"special": true
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"backend": "custom",
|
| 37 |
+
"bos_token": "<s>",
|
| 38 |
+
"clean_up_tokenization_spaces": false,
|
| 39 |
+
"eos_token": "</s>",
|
| 40 |
+
"is_local": true,
|
| 41 |
+
"local_files_only": false,
|
| 42 |
+
"model_max_length": 512,
|
| 43 |
+
"pad_token": "<pad>",
|
| 44 |
+
"separate_vocabs": false,
|
| 45 |
+
"source_lang": null,
|
| 46 |
+
"sp_model_kwargs": {},
|
| 47 |
+
"target_lang": null,
|
| 48 |
+
"tokenizer_class": "MarianTokenizer",
|
| 49 |
+
"unk_token": "<unk>"
|
| 50 |
+
}
|
models/HachimiMT-60-zh-vi/ct2-int8_float32/vocab.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
models/HachimiMT-60-zh-vi/source.spm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d1557335c5892d2f33c6a4864f3ae598b235f705a713e32ca020b9fadb99a99e
|
| 3 |
+
size 606202
|
models/HachimiMT-60-zh-vi/target.spm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d1557335c5892d2f33c6a4864f3ae598b235f705a713e32ca020b9fadb99a99e
|
| 3 |
+
size 606202
|
models/HachimiMT-60-zh-vi/tokenizer_config.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"added_tokens_decoder": {
|
| 3 |
+
"0": {
|
| 4 |
+
"content": "<pad>",
|
| 5 |
+
"lstrip": false,
|
| 6 |
+
"normalized": false,
|
| 7 |
+
"rstrip": false,
|
| 8 |
+
"single_word": false,
|
| 9 |
+
"special": true
|
| 10 |
+
},
|
| 11 |
+
"1": {
|
| 12 |
+
"content": "<s>",
|
| 13 |
+
"lstrip": false,
|
| 14 |
+
"normalized": false,
|
| 15 |
+
"rstrip": false,
|
| 16 |
+
"single_word": false,
|
| 17 |
+
"special": true
|
| 18 |
+
},
|
| 19 |
+
"2": {
|
| 20 |
+
"content": "</s>",
|
| 21 |
+
"lstrip": false,
|
| 22 |
+
"normalized": false,
|
| 23 |
+
"rstrip": false,
|
| 24 |
+
"single_word": false,
|
| 25 |
+
"special": true
|
| 26 |
+
},
|
| 27 |
+
"3": {
|
| 28 |
+
"content": "<unk>",
|
| 29 |
+
"lstrip": false,
|
| 30 |
+
"normalized": false,
|
| 31 |
+
"rstrip": false,
|
| 32 |
+
"single_word": false,
|
| 33 |
+
"special": true
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"backend": "custom",
|
| 37 |
+
"bos_token": "<s>",
|
| 38 |
+
"clean_up_tokenization_spaces": false,
|
| 39 |
+
"eos_token": "</s>",
|
| 40 |
+
"is_local": true,
|
| 41 |
+
"local_files_only": false,
|
| 42 |
+
"model_max_length": 512,
|
| 43 |
+
"pad_token": "<pad>",
|
| 44 |
+
"separate_vocabs": false,
|
| 45 |
+
"source_lang": null,
|
| 46 |
+
"sp_model_kwargs": {},
|
| 47 |
+
"target_lang": null,
|
| 48 |
+
"tokenizer_class": "MarianTokenizer",
|
| 49 |
+
"unk_token": "<unk>"
|
| 50 |
+
}
|
models/HachimiMT-60-zh-vi/vocab.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
sentencepiece>=0.2.0
|
| 2 |
+
gradio>=6.0.0,<7
|
| 3 |
+
ctranslate2>=4.0.0
|
| 4 |
+
huggingface_hub>=0.23.0
|
| 5 |
+
opencc-python-reimplemented>=0.1.7
|
src/app.py
ADDED
|
@@ -0,0 +1,1404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio UI for HachimiMT zh→vi translation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import atexit
|
| 6 |
+
import html
|
| 7 |
+
import os
|
| 8 |
+
import tempfile
|
| 9 |
+
import time
|
| 10 |
+
import unicodedata
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 15 |
+
|
| 16 |
+
import gradio as gr
|
| 17 |
+
|
| 18 |
+
import hardware
|
| 19 |
+
from hardware import detect_hardware_profile
|
| 20 |
+
from progress_tracker import finish_progress, reset_progress, set_progress, snapshot
|
| 21 |
+
from text_preprocess import (
|
| 22 |
+
NORMALIZE_AUTO,
|
| 23 |
+
NORMALIZE_NONE,
|
| 24 |
+
NORMALIZE_T2S,
|
| 25 |
+
normalization_message,
|
| 26 |
+
normalize_chinese_text,
|
| 27 |
+
)
|
| 28 |
+
from honorific_normalize import normalize_honorifics
|
| 29 |
+
from postprocess_policy import classify_genre, v9_route_for_decision
|
| 30 |
+
from pronoun_harmonizer_v9 import harmonize_pronouns_v9
|
| 31 |
+
from translator import MODELS, Backend, HachimiTranslator, is_model_downloaded
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _env_float(name: str, default: float, *, min_value: float = 0.0, max_value: float = 60.0) -> float:
|
| 35 |
+
raw = os.environ.get(name, "").strip()
|
| 36 |
+
if not raw:
|
| 37 |
+
return default
|
| 38 |
+
try:
|
| 39 |
+
return max(min_value, min(max_value, float(raw)))
|
| 40 |
+
except ValueError:
|
| 41 |
+
return default
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 45 |
+
EXPORTS_DIR = ROOT / "exports"
|
| 46 |
+
PID_FILE = ROOT / ".hachimimt.pid"
|
| 47 |
+
APP_PORT = 7860
|
| 48 |
+
# HF Space tự set SPACE_ID. Khi ở Space: CPU-only (ẩn nút cài GPU/torch), process
|
| 49 |
+
# do HF quản (bỏ PID file), HF tự lo host/port (không ép 127.0.0.1). App chạy
|
| 50 |
+
# local KHÔNG đổi gì.
|
| 51 |
+
IS_HF_SPACE = bool(os.environ.get("SPACE_ID") or os.environ.get("SPACE_HOST"))
|
| 52 |
+
MAX_TABLE_ROWS = 300
|
| 53 |
+
# Số ký tự tối đa hiển thị trong ô "Bản dịch đầy đủ" (file xuất .txt vẫn đầy đủ).
|
| 54 |
+
FULL_OUTPUT_DISPLAY_LIMIT = 50_000
|
| 55 |
+
PROGRESS_UPDATE_SECONDS = _env_float("HACHIMIMT_PROGRESS_SECONDS", 0.5, max_value=10.0)
|
| 56 |
+
TEXT_ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "gbk", "big5")
|
| 57 |
+
LEGACY_TEXT_ENCODINGS = ("gb18030", "gbk", "big5")
|
| 58 |
+
|
| 59 |
+
HW_PROFILE = detect_hardware_profile()
|
| 60 |
+
translator = HachimiTranslator(HW_PROFILE)
|
| 61 |
+
EXPORTS_DIR.mkdir(exist_ok=True)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def gpu_available_but_idle() -> bool:
|
| 65 |
+
"""Máy CÓ GPU NVIDIA vật lý nhưng app đang chạy CPU (thiếu torch-CUDA).
|
| 66 |
+
|
| 67 |
+
Đây là nhóm nên được mời cài torch để bật GPU (nhanh hơn nhiều lần).
|
| 68 |
+
Trên HF Space: CPU-only, KHÔNG cho pip install runtime → luôn False (ẩn nút
|
| 69 |
+
cài GPU + khối gpu-hint; mọi chỗ dùng hàm này tự đúng theo).
|
| 70 |
+
"""
|
| 71 |
+
if IS_HF_SPACE:
|
| 72 |
+
return False
|
| 73 |
+
return bool(hardware.PHYSICAL_NVIDIA_GPU) and not HW_PROFILE.has_cuda
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def render_gpu_hint_html() -> str:
|
| 77 |
+
if not gpu_available_but_idle():
|
| 78 |
+
return ""
|
| 79 |
+
gpu = html.escape(hardware.PHYSICAL_GPU_NAME or "GPU NVIDIA")
|
| 80 |
+
return (
|
| 81 |
+
'<div class="gpu-hint">'
|
| 82 |
+
f"⚡ Phát hiện <b>{gpu}</b> nhưng app đang chạy bằng <b>CPU</b> "
|
| 83 |
+
"(thiếu thư viện CUDA). Bật GPU sẽ dịch nhanh hơn nhiều lần. "
|
| 84 |
+
"Bấm nút bên dưới để cài tự động (tải ~2–3 GB, cần ~5 GB ổ trống, một lần)."
|
| 85 |
+
"</div>"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# App khoá ở light mode hoàn toàn bằng CSS: khối ":root, .dark" bên dưới
|
| 89 |
+
# ánh xạ mọi biến theme sang palette "giấy cũ", nên giao diện đúng bất kể
|
| 90 |
+
# trình duyệt đang ở light hay dark (system-theme-proof). Không dùng JS ép
|
| 91 |
+
# theme vì Gradio bỏ qua nó ở đây và CSS đã đủ.
|
| 92 |
+
|
| 93 |
+
# Preload font trong <head> để giảm nhấp nháy khi tải.
|
| 94 |
+
HEAD_HTML = """
|
| 95 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 96 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
CUSTOM_CSS = """
|
| 100 |
+
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,500&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;0,7..72,600;1,7..72,400&family=Noto+Serif+SC:wght@400;600&display=swap');
|
| 101 |
+
|
| 102 |
+
/* ── Palette "giấy cũ / thư phòng" ─────────────────────────────────── */
|
| 103 |
+
:root, .dark {
|
| 104 |
+
--paper: #f4ecdc; /* nền giấy */
|
| 105 |
+
--paper-deep: #e7dac2; /* giấy đậm (đáy gradient) */
|
| 106 |
+
--card: #fbf6ea; /* mặt thẻ */
|
| 107 |
+
--card-soft: #f7efe0; /* thẻ phụ */
|
| 108 |
+
--ink: #221b12; /* mực chính */
|
| 109 |
+
--ink-soft: #514537; /* mực nhạt */
|
| 110 |
+
--muted: #8a7a64; /* chú thích */
|
| 111 |
+
--accent: #b03a26; /* son / chu sa */
|
| 112 |
+
--accent-2: #c8553a; /* son sáng */
|
| 113 |
+
--gold: #9c7b3f; /* nhũ vàng cũ */
|
| 114 |
+
--border: #d8cbb1; /* viền giấy */
|
| 115 |
+
--border-soft:#e6dcc8;
|
| 116 |
+
--shadow: rgba(60, 44, 24, 0.10);
|
| 117 |
+
|
| 118 |
+
/* Ánh xạ vào biến chuẩn của Gradio để mọi widget theo theme này
|
| 119 |
+
(đây là gốc rễ của bug cũ: dark-mode còn sót làm chữ trắng/nền tối). */
|
| 120 |
+
--body-background-fill: transparent;
|
| 121 |
+
--body-text-color: var(--ink);
|
| 122 |
+
--body-text-color-subdued: var(--muted);
|
| 123 |
+
--background-fill-primary: var(--card);
|
| 124 |
+
--background-fill-secondary: var(--card-soft);
|
| 125 |
+
--block-background-fill: var(--card);
|
| 126 |
+
--block-label-background-fill: transparent;
|
| 127 |
+
--block-border-color: var(--border);
|
| 128 |
+
--block-label-text-color: var(--ink-soft);
|
| 129 |
+
--block-title-text-color: var(--ink-soft);
|
| 130 |
+
--border-color-primary: var(--border);
|
| 131 |
+
--border-color-accent: var(--accent);
|
| 132 |
+
--input-background-fill: #fffdf7;
|
| 133 |
+
--input-background-fill-focus: #fffefb;
|
| 134 |
+
--input-border-color: var(--border);
|
| 135 |
+
--input-border-color-focus: var(--accent);
|
| 136 |
+
--input-placeholder-color: #b6a88f;
|
| 137 |
+
--neutral-950: var(--ink);
|
| 138 |
+
--color-accent: var(--accent);
|
| 139 |
+
--color-accent-soft: #efd9c9;
|
| 140 |
+
--link-text-color: var(--accent);
|
| 141 |
+
--table-border-color: var(--border);
|
| 142 |
+
--table-even-background-fill: var(--card);
|
| 143 |
+
--table-odd-background-fill: var(--card-soft);
|
| 144 |
+
--button-secondary-background-fill: #efe6d3;
|
| 145 |
+
--button-secondary-background-fill-hover: #e7dcc4;
|
| 146 |
+
--button-secondary-text-color: var(--ink);
|
| 147 |
+
--button-secondary-border-color: var(--border);
|
| 148 |
+
/* Toast lỗi: dark-mode Gradio đặt nền #0f0e0d (đen) — ép về giấy. */
|
| 149 |
+
--error-background-fill: var(--card);
|
| 150 |
+
--error-border-color: var(--accent);
|
| 151 |
+
--error-text-color: var(--accent);
|
| 152 |
+
--color-red-50: #f7e7e2;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/* ── Nền & khung tổng thể ──────────────────────────────────────────── */
|
| 156 |
+
/* Phủ nền lên html + body + gradio-app: <gradio-app> rộng hết khung và ở
|
| 157 |
+
dark-mode có nền đen — nếu bỏ sót, hai bên (ngoài container 1180px) và
|
| 158 |
+
vùng overscroll sẽ lộ màu đen. Đây là nguồn gốc các "dải đen" còn lại. */
|
| 159 |
+
html { background: #ecdfc9 !important; }
|
| 160 |
+
html, body, gradio-app, .gradio-container {
|
| 161 |
+
background:
|
| 162 |
+
radial-gradient(1200px 600px at 12% -8%, #fbf4e6 0%, transparent 60%),
|
| 163 |
+
radial-gradient(1000px 700px at 110% 0%, #efe2cb 0%, transparent 55%),
|
| 164 |
+
linear-gradient(168deg, #f4ecdc 0%, #ecdfc9 55%, #e4d6bd 100%) !important;
|
| 165 |
+
color: var(--ink) !important;
|
| 166 |
+
}
|
| 167 |
+
gradio-app { display: block; min-height: 100vh; }
|
| 168 |
+
/* Lớp grain giấy rất nhẹ để bớt phẳng */
|
| 169 |
+
.gradio-container::before {
|
| 170 |
+
content: "";
|
| 171 |
+
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
| 172 |
+
opacity: 0.5;
|
| 173 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
|
| 174 |
+
}
|
| 175 |
+
.gradio-container > * { position: relative; z-index: 1; }
|
| 176 |
+
.gradio-container { max-width: 1180px !important; margin: 0 auto !important; }
|
| 177 |
+
|
| 178 |
+
/* ── Typography ────────────────────────────────────────────────────── */
|
| 179 |
+
body, .gradio-container, .prose, button, input, textarea, select, .gr-button {
|
| 180 |
+
font-family: 'Literata', Georgia, 'Times New Roman', serif !important;
|
| 181 |
+
}
|
| 182 |
+
h1, h2, h3, h4, .prose h1, .prose h2, .prose h3 {
|
| 183 |
+
font-family: 'Fraunces', Georgia, serif !important;
|
| 184 |
+
color: var(--ink) !important;
|
| 185 |
+
letter-spacing: -0.01em;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/* ── Header (tiêu đề + triện) ──────────────────────────────────────── */
|
| 189 |
+
/* Quan trọng: ép overflow visible + bỏ giới hạn cao của khối Markdown,
|
| 190 |
+
nếu không Gradio sinh scrollbar con ngay cạnh logo (chữ cao hơn khối
|
| 191 |
+
vài px → overflow:auto). user-select:none để bỏ select nền xanh chướng. */
|
| 192 |
+
#app-header, #app-title {
|
| 193 |
+
overflow: visible !important;
|
| 194 |
+
max-height: none !important;
|
| 195 |
+
user-select: none;
|
| 196 |
+
background: transparent !important;
|
| 197 |
+
border: none !important;
|
| 198 |
+
}
|
| 199 |
+
#app-header { text-align: center; margin: 0.6rem 0 0.2rem; }
|
| 200 |
+
#app-title h1 {
|
| 201 |
+
font-size: clamp(2.4rem, 5vw, 3.4rem) !important;
|
| 202 |
+
font-weight: 600 !important;
|
| 203 |
+
margin: 0 !important;
|
| 204 |
+
line-height: 1.18;
|
| 205 |
+
padding: 0.08em 0;
|
| 206 |
+
background: linear-gradient(180deg, #2a2014 0%, #6a3a26 120%);
|
| 207 |
+
-webkit-background-clip: text; background-clip: text;
|
| 208 |
+
-webkit-text-fill-color: transparent;
|
| 209 |
+
}
|
| 210 |
+
#app-title h1::after {
|
| 211 |
+
content: "譯"; /* "Dịch" — con triện đỏ cạnh tiêu đề */
|
| 212 |
+
-webkit-text-fill-color: #fff;
|
| 213 |
+
font-family: 'Noto Serif SC', serif;
|
| 214 |
+
font-size: 0.42em;
|
| 215 |
+
font-weight: 600;
|
| 216 |
+
vertical-align: 0.5em;
|
| 217 |
+
margin-left: 0.45rem;
|
| 218 |
+
background: var(--accent);
|
| 219 |
+
padding: 0.12em 0.18em 0.04em;
|
| 220 |
+
border-radius: 6px;
|
| 221 |
+
box-shadow: 0 2px 6px rgba(176,58,38,0.35);
|
| 222 |
+
}
|
| 223 |
+
#app-rule {
|
| 224 |
+
width: 90px; height: 2px; margin: 0.7rem auto 1.3rem;
|
| 225 |
+
background: linear-gradient(90deg, transparent, var(--gold), transparent);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/* ── Khối / thẻ ────────────────────────────────────────────────────── */
|
| 229 |
+
.gr-group, .block, .gr-box {
|
| 230 |
+
border-radius: 14px !important;
|
| 231 |
+
border-color: var(--border) !important;
|
| 232 |
+
}
|
| 233 |
+
#settings-card {
|
| 234 |
+
background: var(--card) !important;
|
| 235 |
+
border: 1px solid var(--border) !important;
|
| 236 |
+
border-radius: 16px !important;
|
| 237 |
+
box-shadow: 0 1px 2px var(--shadow), 0 10px 30px -22px var(--shadow);
|
| 238 |
+
padding: 6px 16px 14px !important;
|
| 239 |
+
}
|
| 240 |
+
.section-label, .section-label p {
|
| 241 |
+
font-family: 'Fraunces', Georgia, serif !important;
|
| 242 |
+
font-size: 0.78rem !important;
|
| 243 |
+
font-weight: 600 !important;
|
| 244 |
+
letter-spacing: 0.14em;
|
| 245 |
+
text-transform: uppercase;
|
| 246 |
+
color: var(--gold) !important;
|
| 247 |
+
margin: 0.2rem 0 0.1rem !important;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* Nhãn các control: bỏ kiểu "pill cam" nặng nề của Gradio,
|
| 251 |
+
chuyển thành nhãn chữ nhỏ thanh thoát. */
|
| 252 |
+
.block > label > span,
|
| 253 |
+
span[data-testid="block-info"],
|
| 254 |
+
.gr-form > div > label > span {
|
| 255 |
+
background: transparent !important;
|
| 256 |
+
color: var(--ink-soft) !important;
|
| 257 |
+
font-family: 'Literata', serif !important;
|
| 258 |
+
font-weight: 600 !important;
|
| 259 |
+
font-size: 0.86rem !important;
|
| 260 |
+
letter-spacing: 0.01em;
|
| 261 |
+
padding: 0 0 2px 0 !important;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/* ── Input / textarea ──────────────────────────────────────────────── */
|
| 265 |
+
input, textarea, .gr-input, .wrap.svelte-1ipelgc {
|
| 266 |
+
background: #fffdf7 !important;
|
| 267 |
+
color: var(--ink) !important;
|
| 268 |
+
border-color: var(--border) !important;
|
| 269 |
+
}
|
| 270 |
+
textarea::placeholder, input::placeholder { color: #b6a88f !important; }
|
| 271 |
+
textarea:focus, input:focus { border-color: var(--accent) !important; }
|
| 272 |
+
|
| 273 |
+
.source-input textarea, .file-preview textarea, #full-output textarea {
|
| 274 |
+
font-family: 'Literata', Georgia, serif !important;
|
| 275 |
+
font-size: 1.02rem !important;
|
| 276 |
+
line-height: 1.75 !important;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/* ── Nút ───────────────────────────────────────────────────────────── */
|
| 280 |
+
button.primary, .gr-button-primary, button[variant="primary"] {
|
| 281 |
+
background: linear-gradient(180deg, var(--accent-2), var(--accent)) !important;
|
| 282 |
+
border: 1px solid #93311f !important;
|
| 283 |
+
color: #fff7ef !important;
|
| 284 |
+
font-weight: 600 !important;
|
| 285 |
+
letter-spacing: 0.01em;
|
| 286 |
+
box-shadow: 0 2px 8px -2px rgba(176,58,38,0.5) !important;
|
| 287 |
+
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease !important;
|
| 288 |
+
}
|
| 289 |
+
button.primary:hover, .gr-button-primary:hover {
|
| 290 |
+
filter: brightness(1.05);
|
| 291 |
+
transform: translateY(-1px);
|
| 292 |
+
box-shadow: 0 6px 16px -4px rgba(176,58,38,0.55) !important;
|
| 293 |
+
}
|
| 294 |
+
button.secondary, .gr-button-secondary {
|
| 295 |
+
background: #efe6d3 !important;
|
| 296 |
+
border: 1px solid var(--border) !important;
|
| 297 |
+
color: var(--ink) !important;
|
| 298 |
+
font-weight: 600 !important;
|
| 299 |
+
}
|
| 300 |
+
button.secondary:hover, .gr-button-secondary:hover { background: #e7dcc4 !important; }
|
| 301 |
+
|
| 302 |
+
/* ── Tabs ──────────────────────────────────────────────────────────── */
|
| 303 |
+
/* Bỏ nền/viền/padding thừa của khối bao Tabs để thanh tab liền mạch
|
| 304 |
+
với nền giấy thay vì nằm trong một thẻ riêng. */
|
| 305 |
+
.tabs, .tab-wrapper, .tabitem {
|
| 306 |
+
background: transparent !important;
|
| 307 |
+
border: none !important;
|
| 308 |
+
box-shadow: none !important;
|
| 309 |
+
padding-top: 0 !important;
|
| 310 |
+
}
|
| 311 |
+
.tab-nav { border-bottom: 1px solid var(--border) !important; }
|
| 312 |
+
.tab-nav button {
|
| 313 |
+
font-family: 'Fraunces', Georgia, serif !important;
|
| 314 |
+
font-size: 1rem !important;
|
| 315 |
+
color: var(--muted) !important;
|
| 316 |
+
}
|
| 317 |
+
.tab-nav button.selected {
|
| 318 |
+
color: var(--accent) !important;
|
| 319 |
+
border-bottom: 2px solid var(--accent) !important;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* ── Radio / Checkbox ──────────────────────────────────────────────── */
|
| 323 |
+
/* Mặc định (chưa chọn): nền giấy, chữ mực — ghi đè màu tối còn sót của
|
| 324 |
+
dark-mode Gradio (đây là chỗ widget bị "đen" trên nền sáng). */
|
| 325 |
+
.gr-check-radio label,
|
| 326 |
+
fieldset label:has(input[type="radio"]),
|
| 327 |
+
fieldset label:has(input[type="checkbox"]) {
|
| 328 |
+
background: #f1e7d4 !important;
|
| 329 |
+
color: var(--ink) !important;
|
| 330 |
+
border: 1px solid var(--border) !important;
|
| 331 |
+
border-radius: 9px !important;
|
| 332 |
+
transition: background 0.12s ease, border-color 0.12s ease !important;
|
| 333 |
+
}
|
| 334 |
+
.gr-check-radio label:hover,
|
| 335 |
+
fieldset label:has(input[type="radio"]):hover,
|
| 336 |
+
fieldset label:has(input[type="checkbox"]):hover {
|
| 337 |
+
background: #ebe0ca !important;
|
| 338 |
+
border-color: var(--gold) !important;
|
| 339 |
+
}
|
| 340 |
+
/* Đang chọn: nền son nhạt, viền son */
|
| 341 |
+
.gr-check-radio label.selected, label.selected,
|
| 342 |
+
fieldset label:has(input:checked) {
|
| 343 |
+
background: var(--color-accent-soft) !important;
|
| 344 |
+
border-color: var(--accent) !important;
|
| 345 |
+
color: var(--ink) !important;
|
| 346 |
+
}
|
| 347 |
+
/* Chấm radio / ô checkbox khi tick */
|
| 348 |
+
input[type="radio"]:checked, input[type="checkbox"]:checked {
|
| 349 |
+
background-color: var(--accent) !important;
|
| 350 |
+
border-color: var(--accent) !important;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/* ── Accordion "Tuỳ chọn chuẩn hóa xưng hô" (collapse, mặc định đóng) ───
|
| 354 |
+
3 checkbox cùng chủ đề, tách hẳn khỏi card cấu hình dịch. Label gọn 1 dòng,
|
| 355 |
+
bỏ info dài của Gradio (gây ngộp) → 1 dòng hint chung bên dưới. */
|
| 356 |
+
#honorific-accordion label { font-weight: 500 !important; white-space: nowrap; }
|
| 357 |
+
#honorific-accordion .gr-check-radio span,
|
| 358 |
+
#honorific-accordion label > span:not(:first-child) { font-size: 0.92rem !important; }
|
| 359 |
+
/* Gradio render info trong block-info/.info — ẩn để khỏi chiếm 3-4 dòng/ô */
|
| 360 |
+
#honorific-accordion div[data-testid="block-info"],
|
| 361 |
+
#honorific-accordion .info { display: none !important; }
|
| 362 |
+
/* dòng hint chung: nhỏ, italic, mực nhạt */
|
| 363 |
+
#honorific-accordion .honorific-hint, #honorific-accordion .honorific-hint p {
|
| 364 |
+
font-size: 0.82rem !important;
|
| 365 |
+
font-style: italic;
|
| 366 |
+
color: var(--muted) !important;
|
| 367 |
+
margin: 10px 2px 2px !important;
|
| 368 |
+
line-height: 1.45 !important;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
/* ── Slider ────────────────────────────────────────────────────────── */
|
| 372 |
+
input[type="range"]::-webkit-slider-thumb { background: var(--accent) !important; }
|
| 373 |
+
.gr-slider .head, .slider_input_container .slider { accent-color: var(--accent) !important; }
|
| 374 |
+
|
| 375 |
+
/* ── Thanh tiến trình tuỳ biến ─────────────────────────────────────── */
|
| 376 |
+
.progress-wrap {
|
| 377 |
+
border: 1px solid var(--border);
|
| 378 |
+
background: var(--card);
|
| 379 |
+
border-radius: 14px;
|
| 380 |
+
padding: 14px 18px;
|
| 381 |
+
box-shadow: 0 1px 2px var(--shadow);
|
| 382 |
+
}
|
| 383 |
+
.progress-head {
|
| 384 |
+
display: flex; justify-content: space-between; align-items: baseline;
|
| 385 |
+
margin-bottom: 9px;
|
| 386 |
+
}
|
| 387 |
+
.progress-pct {
|
| 388 |
+
font-family: 'Fraunces', Georgia, serif;
|
| 389 |
+
font-size: 1.15rem; font-weight: 600; color: var(--accent);
|
| 390 |
+
}
|
| 391 |
+
.progress-state {
|
| 392 |
+
font-size: 0.85rem; color: var(--muted); font-style: italic;
|
| 393 |
+
}
|
| 394 |
+
.progress-track {
|
| 395 |
+
height: 9px; border-radius: 999px;
|
| 396 |
+
background: #e6dac2; overflow: hidden;
|
| 397 |
+
box-shadow: inset 0 1px 2px rgba(90,66,30,0.18);
|
| 398 |
+
}
|
| 399 |
+
.progress-fill {
|
| 400 |
+
height: 100%; border-radius: 999px;
|
| 401 |
+
background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--gold));
|
| 402 |
+
background-size: 200% 100%;
|
| 403 |
+
transition: width 0.3s ease;
|
| 404 |
+
}
|
| 405 |
+
.progress-fill.is-running { animation: progress-shine 1.6s linear infinite; }
|
| 406 |
+
@keyframes progress-shine { to { background-position: 200% 0; } }
|
| 407 |
+
.progress-msg { margin-top: 8px; font-size: 0.92rem; color: var(--ink-soft); }
|
| 408 |
+
|
| 409 |
+
/* ── Khung đối chiếu song song (xen kẽ câu Trung · Việt) ────────────── */
|
| 410 |
+
#compare-view { max-height: 560px; overflow-y: auto; padding-right: 6px; }
|
| 411 |
+
/* Thanh cuộn mảnh hợp tông giấy */
|
| 412 |
+
#compare-view::-webkit-scrollbar { width: 9px; }
|
| 413 |
+
#compare-view::-webkit-scrollbar-thumb { background: #d2c3a6; border-radius: 999px; }
|
| 414 |
+
#compare-view::-webkit-scrollbar-track { background: transparent; }
|
| 415 |
+
|
| 416 |
+
.compare-list { display: flex; flex-direction: column; }
|
| 417 |
+
/* Lưới 3 cột: số thứ tự · câu Trung · câu Việt. Cột 1fr/1fr tự wrap nên
|
| 418 |
+
không tràn ngang (lỗi cũ của Dataframe). */
|
| 419 |
+
.cmp-head, .cmp-row {
|
| 420 |
+
display: grid;
|
| 421 |
+
grid-template-columns: 2rem 1fr 1fr;
|
| 422 |
+
gap: 16px;
|
| 423 |
+
align-items: start;
|
| 424 |
+
}
|
| 425 |
+
.cmp-head {
|
| 426 |
+
position: sticky; top: 0; z-index: 2;
|
| 427 |
+
padding: 4px 8px 8px;
|
| 428 |
+
background: linear-gradient(180deg, var(--card) 70%, rgba(251,246,234,0));
|
| 429 |
+
border-bottom: 1px solid var(--border);
|
| 430 |
+
}
|
| 431 |
+
.cmp-col {
|
| 432 |
+
font-family: 'Fraunces', Georgia, serif;
|
| 433 |
+
font-size: 0.82rem; font-weight: 600; letter-spacing: 0.04em;
|
| 434 |
+
color: var(--gold);
|
| 435 |
+
text-transform: uppercase;
|
| 436 |
+
}
|
| 437 |
+
.cmp-row {
|
| 438 |
+
padding: 13px 8px 14px;
|
| 439 |
+
border-bottom: 1px solid var(--border-soft);
|
| 440 |
+
border-radius: 10px;
|
| 441 |
+
transition: background 0.12s ease;
|
| 442 |
+
}
|
| 443 |
+
.cmp-row:last-child { border-bottom: none; }
|
| 444 |
+
/* Linked highlight: rê vào hàng → cả ô Trung và ô Việt cùng sáng */
|
| 445 |
+
.cmp-row:hover { background: #f4e8d2; }
|
| 446 |
+
.cmp-num {
|
| 447 |
+
grid-column: 1;
|
| 448 |
+
min-width: 1.8rem; height: 1.8rem;
|
| 449 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 450 |
+
font-family: 'Fraunces', Georgia, serif;
|
| 451 |
+
font-size: 0.82rem; font-weight: 600;
|
| 452 |
+
color: var(--accent);
|
| 453 |
+
background: #f3e7d3;
|
| 454 |
+
border: 1px solid var(--border);
|
| 455 |
+
border-radius: 999px;
|
| 456 |
+
}
|
| 457 |
+
.cmp-row:hover .cmp-num { background: var(--accent); color: #fff7ef; border-color: var(--accent); }
|
| 458 |
+
.cmp-zh {
|
| 459 |
+
grid-column: 2; min-width: 0;
|
| 460 |
+
font-family: 'Noto Serif SC', serif;
|
| 461 |
+
font-size: 1.0rem; line-height: 1.7;
|
| 462 |
+
color: var(--ink-soft);
|
| 463 |
+
margin: 0;
|
| 464 |
+
}
|
| 465 |
+
.cmp-vi {
|
| 466 |
+
grid-column: 3; min-width: 0;
|
| 467 |
+
font-family: 'Literata', Georgia, serif;
|
| 468 |
+
font-size: 1.02rem; line-height: 1.7;
|
| 469 |
+
color: var(--ink);
|
| 470 |
+
margin: 0;
|
| 471 |
+
}
|
| 472 |
+
.cmp-note {
|
| 473 |
+
padding: 12px 6px 2px; font-size: 0.88rem; font-style: italic;
|
| 474 |
+
color: var(--muted);
|
| 475 |
+
}
|
| 476 |
+
.compare-empty {
|
| 477 |
+
padding: 22px 8px; text-align: center;
|
| 478 |
+
color: var(--muted); font-style: italic; font-size: 0.95rem;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
/* ── Accordion ─────────────────────────────────────────────────────── */
|
| 482 |
+
/* Header accordion mặc định lấy --body-text-color (trắng ở dark-mode) →
|
| 483 |
+
gần như vô hình trên nền giấy. Ép chữ mực + nền giấy cho rõ. */
|
| 484 |
+
.gr-accordion {
|
| 485 |
+
background: var(--card) !important;
|
| 486 |
+
border: 1px solid var(--border) !important;
|
| 487 |
+
border-radius: 12px !important;
|
| 488 |
+
}
|
| 489 |
+
.gr-accordion .label-wrap, .gr-accordion .label-wrap span, .gr-accordion button.label-wrap {
|
| 490 |
+
color: var(--ink-soft) !important;
|
| 491 |
+
font-family: 'Fraunces', Georgia, serif !important;
|
| 492 |
+
font-weight: 600 !important;
|
| 493 |
+
}
|
| 494 |
+
.gr-accordion .label-wrap:hover, .gr-accordion .label-wrap:hover span {
|
| 495 |
+
color: var(--accent) !important;
|
| 496 |
+
}
|
| 497 |
+
.gr-accordion .label-wrap .icon, .gr-accordion .label-wrap svg { color: var(--accent) !important; }
|
| 498 |
+
|
| 499 |
+
/* ── Khối thông tin máy / engine ───────────────────────────────────── */
|
| 500 |
+
.info-card {
|
| 501 |
+
background: var(--card-soft) !important;
|
| 502 |
+
border: 1px solid var(--border-soft) !important;
|
| 503 |
+
border-radius: 12px !important;
|
| 504 |
+
padding: 10px 16px !important;
|
| 505 |
+
font-size: 0.9rem;
|
| 506 |
+
color: var(--ink-soft) !important;
|
| 507 |
+
}
|
| 508 |
+
.info-card p { margin: 0.2rem 0 !important; color: var(--ink-soft) !important; }
|
| 509 |
+
.info-card strong, .info-card code { color: var(--ink) !important; }
|
| 510 |
+
code, .prose code {
|
| 511 |
+
background: #efe4cf !important;
|
| 512 |
+
color: var(--accent) !important;
|
| 513 |
+
border-radius: 5px;
|
| 514 |
+
padding: 0.05em 0.4em;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
/* ── Toast thông báo / lỗi ─────────────────────────────────────────── */
|
| 518 |
+
/* Ghi đè trực tiếp (biến cấp .dark của Gradio thắng được khai báo :root),
|
| 519 |
+
đảm bảo toast luôn nền giấy + chữ son, không bị nền đen của dark-mode. */
|
| 520 |
+
.toast-body {
|
| 521 |
+
background: var(--card) !important;
|
| 522 |
+
border: 1px solid var(--border) !important;
|
| 523 |
+
color: var(--ink) !important;
|
| 524 |
+
box-shadow: 0 8px 28px -8px var(--shadow) !important;
|
| 525 |
+
}
|
| 526 |
+
.toast-body.error, .toast-body.warning, .toast-body.info {
|
| 527 |
+
border-left: 4px solid var(--accent) !important;
|
| 528 |
+
}
|
| 529 |
+
.toast-title, .toast-text, .toast-body * { color: var(--ink) !important; }
|
| 530 |
+
.toast-icon, .toast-body svg { color: var(--accent) !important; fill: var(--accent) !important; }
|
| 531 |
+
.toast-close { color: var(--muted) !important; }
|
| 532 |
+
/* Thanh đếm ngược của toast */
|
| 533 |
+
.timer { background: var(--accent) !important; }
|
| 534 |
+
|
| 535 |
+
/* ── Banner kết quả ────────────────────────────────────────────────── */
|
| 536 |
+
/* Rỗng (chưa dịch) → ẩn hẳn, tránh thẻ kem trống chiếm chỗ. */
|
| 537 |
+
#result-summary:not(:has(p)):not(:has(li)) { display: none !important; }
|
| 538 |
+
#result-summary:has(p), #result-summary:has(li) {
|
| 539 |
+
background: linear-gradient(180deg, #f7ece0, #f2e3d2) !important;
|
| 540 |
+
border: 1px solid var(--border) !important;
|
| 541 |
+
border-left: 4px solid var(--accent) !important;
|
| 542 |
+
border-radius: 12px !important;
|
| 543 |
+
padding: 12px 18px !important;
|
| 544 |
+
margin: 4px 0 6px !important;
|
| 545 |
+
}
|
| 546 |
+
#result-summary p { margin: 0.15rem 0 !important; color: var(--ink) !important; }
|
| 547 |
+
|
| 548 |
+
/* ── Badge trạng thái tải model ────────────────────────────────────── */
|
| 549 |
+
#model-badge { margin: -0.2rem 0 0.1rem; }
|
| 550 |
+
.model-meta {
|
| 551 |
+
display: flex;
|
| 552 |
+
align-items: center;
|
| 553 |
+
gap: 0.6rem;
|
| 554 |
+
flex-wrap: wrap;
|
| 555 |
+
}
|
| 556 |
+
.model-hf-link {
|
| 557 |
+
font-size: 0.8rem;
|
| 558 |
+
color: var(--accent) !important;
|
| 559 |
+
text-decoration: none;
|
| 560 |
+
border-bottom: 1px solid transparent;
|
| 561 |
+
transition: border-color 0.15s;
|
| 562 |
+
}
|
| 563 |
+
.model-hf-link:hover { border-bottom-color: var(--accent); }
|
| 564 |
+
.model-badge {
|
| 565 |
+
display: inline-block;
|
| 566 |
+
font-size: 0.82rem;
|
| 567 |
+
font-weight: 500;
|
| 568 |
+
padding: 0.18em 0.7em;
|
| 569 |
+
border-radius: 999px;
|
| 570 |
+
border: 1px solid var(--border);
|
| 571 |
+
letter-spacing: 0.01em;
|
| 572 |
+
}
|
| 573 |
+
.model-badge.ready {
|
| 574 |
+
background: #e8efe0;
|
| 575 |
+
color: #3f5a32;
|
| 576 |
+
border-color: #c2d2b0;
|
| 577 |
+
}
|
| 578 |
+
.model-badge.pending {
|
| 579 |
+
background: #f6ecd6;
|
| 580 |
+
color: var(--gold);
|
| 581 |
+
border-color: #ddcaa5;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
/* ── Gợi ý bật GPU (chỉ hiện khi có GPU NVIDIA nhưng đang chạy CPU) ──── */
|
| 585 |
+
#gpu-hint-box {
|
| 586 |
+
background: linear-gradient(180deg, #f3ece2, #efe6d4) !important;
|
| 587 |
+
border: 1px solid var(--border) !important;
|
| 588 |
+
border-left: 4px solid var(--gold) !important;
|
| 589 |
+
border-radius: 12px !important;
|
| 590 |
+
padding: 12px 16px !important;
|
| 591 |
+
margin: 4px 0 2px !important;
|
| 592 |
+
}
|
| 593 |
+
.gpu-hint {
|
| 594 |
+
color: var(--ink-soft);
|
| 595 |
+
font-size: 0.9rem;
|
| 596 |
+
line-height: 1.5;
|
| 597 |
+
margin-bottom: 0.5rem;
|
| 598 |
+
}
|
| 599 |
+
.gpu-hint b { color: var(--ink); }
|
| 600 |
+
|
| 601 |
+
footer { display: none !important; }
|
| 602 |
+
"""
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def write_pid_file() -> None:
|
| 606 |
+
PID_FILE.write_text(str(os.getpid()), encoding="utf-8")
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def remove_pid_file() -> None:
|
| 610 |
+
PID_FILE.unlink(missing_ok=True)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def resolve_batch_size(auto_batch: bool, manual_batch: float) -> int:
|
| 614 |
+
if auto_batch:
|
| 615 |
+
profile = detect_hardware_profile()
|
| 616 |
+
translator.apply_hardware_profile(profile)
|
| 617 |
+
return profile.batch_size
|
| 618 |
+
batch = int(manual_batch)
|
| 619 |
+
translator.set_batch_size(batch)
|
| 620 |
+
return batch
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def ensure_model(model_key: str, backend: str, beam_size: float) -> str:
|
| 624 |
+
status = translator.load(model_key, backend=backend)
|
| 625 |
+
beam = HachimiTranslator.clamp_beam(beam_size)
|
| 626 |
+
return f"{status} · beam={beam} · batch={translator.batch_size}"
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def on_auto_batch_toggle(auto_batch: bool) -> dict:
|
| 630 |
+
profile = detect_hardware_profile()
|
| 631 |
+
return gr.update(value=profile.batch_size, interactive=not auto_batch)
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def _model_hf_link(config) -> str:
|
| 635 |
+
url = html.escape(f"https://huggingface.co/{config.model_id}", quote=True)
|
| 636 |
+
return (
|
| 637 |
+
f'<a class="model-hf-link" href="{url}" target="_blank" rel="noopener">'
|
| 638 |
+
"↗ Trang Hugging Face</a>"
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def render_model_badge(model_key: str, backend: str) -> str:
|
| 643 |
+
"""Badge cho biết model (theo engine đang chọn) đã tải sẵn hay sẽ phải tải,
|
| 644 |
+
kèm link tới trang Hugging Face của model."""
|
| 645 |
+
if model_key not in MODELS:
|
| 646 |
+
return ""
|
| 647 |
+
config = MODELS[model_key]
|
| 648 |
+
link = _model_hf_link(config)
|
| 649 |
+
|
| 650 |
+
if is_model_downloaded(model_key, backend):
|
| 651 |
+
badge = '<span class="model-badge ready">✓ Đã tải — dịch được ngay</span>'
|
| 652 |
+
return f'<div class="model-meta">{badge}{link}</div>'
|
| 653 |
+
|
| 654 |
+
if backend == Backend.CT2.value and config.ct2_size_mb:
|
| 655 |
+
size = f" (~{config.ct2_size_mb} MB)"
|
| 656 |
+
elif backend != Backend.CT2.value:
|
| 657 |
+
size = " (bản PyTorch, nặng hơn)"
|
| 658 |
+
else:
|
| 659 |
+
size = ""
|
| 660 |
+
badge = (
|
| 661 |
+
f'<span class="model-badge pending">⬇ Chưa có{size} — '
|
| 662 |
+
"sẽ tự tải từ Hugging Face ở lần dịch đầu</span>"
|
| 663 |
+
)
|
| 664 |
+
return f'<div class="model-meta">{badge}{link}</div>'
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def on_model_change(model_key: str, backend: str) -> tuple[float, str]:
|
| 668 |
+
return float(MODELS[model_key].default_beam), render_model_badge(model_key, backend)
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def install_gpu_torch_ui():
|
| 672 |
+
"""Handler nút 'Cài torch để bật GPU'. Stream log pip realtime vào textbox.
|
| 673 |
+
|
| 674 |
+
Yield TỪNG dòng pip ngay khi có, nên người dùng thấy tiến trình tải/cài liên
|
| 675 |
+
tục (không bị 'đứng hình' như khi gọi hàm blocking rồi mới in một lần)."""
|
| 676 |
+
if not gpu_available_but_idle():
|
| 677 |
+
yield gr.update(), "Không cần cài: máy không có GPU NVIDIA đang rảnh."
|
| 678 |
+
return
|
| 679 |
+
|
| 680 |
+
from gpu_setup import _stream_pip, choose_cuda_channel, torch_install_command, verify_torch_cuda
|
| 681 |
+
|
| 682 |
+
channel = choose_cuda_channel(hardware.DRIVER_CUDA_VERSION)
|
| 683 |
+
if channel is None:
|
| 684 |
+
yield gr.update(), (
|
| 685 |
+
"Driver NVIDIA quá cũ so với các bản torch CUDA hiện có. "
|
| 686 |
+
"Hãy cập nhật driver rồi thử lại."
|
| 687 |
+
)
|
| 688 |
+
return
|
| 689 |
+
|
| 690 |
+
logs = [
|
| 691 |
+
f"Cài torch CUDA ({channel}) — tải ~2–3 GB, cần ~5 GB ổ trống. "
|
| 692 |
+
"Vui lòng đợi và ĐỪNG tắt app…",
|
| 693 |
+
]
|
| 694 |
+
yield gr.update(interactive=False, value="Đang cài… đừng tắt app"), "\n".join(logs)
|
| 695 |
+
|
| 696 |
+
exit_code = None
|
| 697 |
+
for line in _stream_pip(torch_install_command(channel)):
|
| 698 |
+
if line.startswith("__EXIT__:"):
|
| 699 |
+
exit_code = int(line.split(":", 1)[1])
|
| 700 |
+
continue
|
| 701 |
+
if line:
|
| 702 |
+
logs.append(line)
|
| 703 |
+
# Giữ textbox gọn: chỉ hiện 200 dòng cuối.
|
| 704 |
+
yield gr.update(interactive=False), "\n".join(logs[-200:])
|
| 705 |
+
|
| 706 |
+
if exit_code != 0:
|
| 707 |
+
logs.append("")
|
| 708 |
+
logs.append(f"❌ Cài thất bại (mã {exit_code}). Kiểm tra mạng/đĩa hoặc cài thủ công (README).")
|
| 709 |
+
yield (
|
| 710 |
+
gr.update(interactive=True, value="Thử cài lại"),
|
| 711 |
+
"\n".join(logs[-200:]),
|
| 712 |
+
)
|
| 713 |
+
return
|
| 714 |
+
|
| 715 |
+
# pip exit 0 chưa chắc có CUDA (vd đã có torch-CPU). Xác minh thật.
|
| 716 |
+
logs.append("")
|
| 717 |
+
logs.append("Đang kiểm tra torch có nhận GPU không…")
|
| 718 |
+
yield gr.update(interactive=False), "\n".join(logs[-200:])
|
| 719 |
+
|
| 720 |
+
ok, verify_msg = verify_torch_cuda()
|
| 721 |
+
logs.append(("✅ " if ok else "❌ ") + verify_msg)
|
| 722 |
+
if ok:
|
| 723 |
+
logs.append("Hãy TẮT và MỞ LẠI app (stop.bat rồi start.bat) để bật GPU.")
|
| 724 |
+
yield (
|
| 725 |
+
gr.update(
|
| 726 |
+
interactive=not ok,
|
| 727 |
+
value="Cài xong — khởi động lại app" if ok else "Thử cài lại",
|
| 728 |
+
),
|
| 729 |
+
"\n".join(logs[-200:]),
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def _engine_hint_text(backend: str) -> str:
|
| 734 |
+
if backend == Backend.CT2.value:
|
| 735 |
+
return "**Engine:** CTranslate2 — khuyên dùng, tận dụng GPU + batch tốt, nhanh nhất."
|
| 736 |
+
return (
|
| 737 |
+
"**Engine:** PyTorch — đã hỗ trợ batch GPU, nhưng vẫn chậm hơn CT2. "
|
| 738 |
+
"GPU % thấp là bình thường với model ~60M."
|
| 739 |
+
)
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
def on_backend_change(backend: str, model_key: str) -> tuple[str, str]:
|
| 743 |
+
"""Đổi engine → cập nhật cả gợi ý engine lẫn badge trạng thái tải của model."""
|
| 744 |
+
return _engine_hint_text(backend), render_model_badge(model_key, backend)
|
| 745 |
+
|
| 746 |
+
|
| 747 |
+
def render_progress_html(pct: float, message: str, running: bool) -> str:
|
| 748 |
+
status = "Đang dịch…" if running else "Sẵn sàng"
|
| 749 |
+
safe_message = html.escape(message, quote=True)
|
| 750 |
+
safe_status = html.escape(status, quote=True)
|
| 751 |
+
running_cls = " is-running" if running else ""
|
| 752 |
+
return f"""
|
| 753 |
+
<div class="progress-wrap">
|
| 754 |
+
<div class="progress-head">
|
| 755 |
+
<span class="progress-pct">{pct:.0f}%</span>
|
| 756 |
+
<span class="progress-state">{safe_status}</span>
|
| 757 |
+
</div>
|
| 758 |
+
<div class="progress-track">
|
| 759 |
+
<div class="progress-fill{running_cls}" style="width: {pct:.1f}%;"></div>
|
| 760 |
+
</div>
|
| 761 |
+
<div class="progress-msg">{safe_message}</div>
|
| 762 |
+
</div>
|
| 763 |
+
"""
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
def poll_progress_ui() -> str:
|
| 767 |
+
state = snapshot()
|
| 768 |
+
return render_progress_html(state.pct, state.message, state.running)
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
EMPTY_COMPARE_HTML = (
|
| 772 |
+
'<div class="compare-empty">Dịch xong, câu gốc và bản dịch sẽ hiện đối chiếu ở đây '
|
| 773 |
+
"— từng câu một, cuộn dọc để đọc soát.</div>"
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def render_compare_html(rows: list[tuple[int, str, str]]) -> str:
|
| 778 |
+
"""Render đối chiếu song song 2 cột (Trung | Việt), mỗi chunk một hàng.
|
| 779 |
+
|
| 780 |
+
Mỗi hàng là một cặp câu đã khớp sẵn từ backend → rê chuột vào một hàng
|
| 781 |
+
thì cả ô Trung lẫn ô Việt cùng sáng (linked highlight kiểu Google Dịch),
|
| 782 |
+
chỉ bằng CSS :hover vì đây là markup của ta trong gr.HTML."""
|
| 783 |
+
if not rows:
|
| 784 |
+
return EMPTY_COMPARE_HTML
|
| 785 |
+
|
| 786 |
+
display = rows[:MAX_TABLE_ROWS]
|
| 787 |
+
items = [
|
| 788 |
+
'<div class="cmp-head">'
|
| 789 |
+
'<span class="cmp-num"></span>'
|
| 790 |
+
'<div class="cmp-col">Tiếng Trung</div>'
|
| 791 |
+
'<div class="cmp-col">Tiếng Việt</div>'
|
| 792 |
+
"</div>"
|
| 793 |
+
]
|
| 794 |
+
for idx, zh, vi in display:
|
| 795 |
+
safe_zh = html.escape(zh, quote=True)
|
| 796 |
+
safe_vi = html.escape(vi, quote=True)
|
| 797 |
+
items.append(
|
| 798 |
+
f'<div class="cmp-row">'
|
| 799 |
+
f'<span class="cmp-num">{idx}</span>'
|
| 800 |
+
f'<p class="cmp-zh">{safe_zh}</p>'
|
| 801 |
+
f'<p class="cmp-vi">{safe_vi}</p>'
|
| 802 |
+
f"</div>"
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
note = ""
|
| 806 |
+
if len(rows) > MAX_TABLE_ROWS:
|
| 807 |
+
note = (
|
| 808 |
+
f'<div class="cmp-note">Hiển thị {MAX_TABLE_ROWS}/{len(rows)} câu đầu. '
|
| 809 |
+
"Xem bản dịch đầy đủ ở khối bên dưới.</div>"
|
| 810 |
+
)
|
| 811 |
+
return f'<div class="compare-list">{"".join(items)}{note}</div>'
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
def _decoded_text_score(text: str) -> int:
|
| 815 |
+
"""Lower is better. Helps avoid Big5 bytes decoded as valid GB18030 junk."""
|
| 816 |
+
score = 0
|
| 817 |
+
for char in text:
|
| 818 |
+
if char == "\ufffd":
|
| 819 |
+
score += 100
|
| 820 |
+
continue
|
| 821 |
+
if char in "\n\r\t":
|
| 822 |
+
continue
|
| 823 |
+
category = unicodedata.category(char)
|
| 824 |
+
if category.startswith("C"):
|
| 825 |
+
score += 20
|
| 826 |
+
continue
|
| 827 |
+
name = unicodedata.name(char, "")
|
| 828 |
+
if (
|
| 829 |
+
"HIRAGANA" in name
|
| 830 |
+
or "KATAKANA" in name
|
| 831 |
+
or "BOPOMOFO" in name
|
| 832 |
+
or "HANGUL" in name
|
| 833 |
+
):
|
| 834 |
+
score += 6
|
| 835 |
+
return score
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
def _decode_text_bytes(data: bytes) -> str:
|
| 839 |
+
for encoding in ("utf-8-sig", "utf-8"):
|
| 840 |
+
try:
|
| 841 |
+
return data.decode(encoding)
|
| 842 |
+
except UnicodeDecodeError:
|
| 843 |
+
continue
|
| 844 |
+
|
| 845 |
+
candidates: list[tuple[int, int, str]] = []
|
| 846 |
+
for index, encoding in enumerate(LEGACY_TEXT_ENCODINGS):
|
| 847 |
+
try:
|
| 848 |
+
decoded = data.decode(encoding)
|
| 849 |
+
except UnicodeDecodeError:
|
| 850 |
+
continue
|
| 851 |
+
candidates.append((_decoded_text_score(decoded), index, decoded))
|
| 852 |
+
|
| 853 |
+
if candidates:
|
| 854 |
+
return min(candidates, key=lambda item: (item[0], item[1]))[2]
|
| 855 |
+
return data.decode("utf-8", errors="replace")
|
| 856 |
+
|
| 857 |
+
|
| 858 |
+
def read_text_file(path: Path, *, max_chars: int | None = None) -> str:
|
| 859 |
+
text = _decode_text_bytes(path.read_bytes())
|
| 860 |
+
return text[:max_chars] if max_chars is not None else text
|
| 861 |
+
|
| 862 |
+
|
| 863 |
+
def _exception_message(exc: Exception) -> str:
|
| 864 |
+
return str(exc).strip() or exc.__class__.__name__
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
def export_translation(full_text: str, filename_stem: str) -> str | None:
|
| 868 |
+
if not full_text.strip():
|
| 869 |
+
raise gr.Error("Chưa có bản dịch để xuất.")
|
| 870 |
+
|
| 871 |
+
safe_stem = "".join(c if c.isalnum() or c in "-_" else "_" for c in filename_stem) or "translation"
|
| 872 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 873 |
+
out_path = EXPORTS_DIR / f"{safe_stem}_vi_{timestamp}.txt"
|
| 874 |
+
out_path.write_text(full_text, encoding="utf-8")
|
| 875 |
+
return str(out_path)
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def _format_duration(seconds: float) -> str:
|
| 879 |
+
"""Định dạng thời gian gọn: '12,3 giây' hoặc '2 phút 5 giây'."""
|
| 880 |
+
if seconds < 60:
|
| 881 |
+
return f"{seconds:.1f} giây".replace(".", ",")
|
| 882 |
+
minutes = int(seconds // 60)
|
| 883 |
+
rem = int(round(seconds - minutes * 60))
|
| 884 |
+
if rem == 0:
|
| 885 |
+
return f"{minutes} phút"
|
| 886 |
+
return f"{minutes} phút {rem} giây"
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
def _clamp_full_text(full_text: str) -> str:
|
| 890 |
+
"""Giới hạn text hiển thị trong ô 'Bản dịch đầy đủ' để tránh lag với file lớn.
|
| 891 |
+
|
| 892 |
+
Textbox chứa vài MB (truyện hàng chục nghìn câu) làm trình duyệt giật khi
|
| 893 |
+
render/cuộn. File xuất .txt vẫn ĐẦY ĐỦ — đây chỉ cắt phần HIỂN THỊ. User đọc
|
| 894 |
+
trọn bộ bằng nút tải file bên dưới.
|
| 895 |
+
"""
|
| 896 |
+
if len(full_text) <= FULL_OUTPUT_DISPLAY_LIMIT:
|
| 897 |
+
return full_text
|
| 898 |
+
head = full_text[:FULL_OUTPUT_DISPLAY_LIMIT].rsplit("\n", 1)[0]
|
| 899 |
+
omitted = len(full_text) - len(head)
|
| 900 |
+
return (
|
| 901 |
+
f"{head}\n\n"
|
| 902 |
+
f"────────────────────\n"
|
| 903 |
+
f"[Đã ẩn ~{omitted:,} ký tự còn lại để tránh lag. "
|
| 904 |
+
f"Bản dịch ĐẦY ĐỦ đã lưu — bấm “Xuất bản dịch .txt” / tải file bên dưới.]"
|
| 905 |
+
).replace(",", ".")
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def _build_results(
|
| 909 |
+
rows: list[tuple[int, str, str]],
|
| 910 |
+
full_text: str,
|
| 911 |
+
status: str,
|
| 912 |
+
summary: str,
|
| 913 |
+
download_path: str | None,
|
| 914 |
+
) -> tuple:
|
| 915 |
+
return (
|
| 916 |
+
render_compare_html(rows),
|
| 917 |
+
_clamp_full_text(full_text),
|
| 918 |
+
status,
|
| 919 |
+
download_path,
|
| 920 |
+
summary,
|
| 921 |
+
full_text, # bản đầy đủ → gr.State cho nút Xuất .txt
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
def apply_postprocess_rows(
|
| 926 |
+
rows: list[tuple[int, str, str]],
|
| 927 |
+
*,
|
| 928 |
+
honorific_kinship: bool,
|
| 929 |
+
honorific_pronouns: bool,
|
| 930 |
+
pronoun_harmonizer_v9: bool,
|
| 931 |
+
) -> tuple[list[tuple[int, str, str]], str, dict]:
|
| 932 |
+
genre_decision = classify_genre(rows) if rows else None
|
| 933 |
+
honorific_changed = 0
|
| 934 |
+
pronoun_report = {}
|
| 935 |
+
|
| 936 |
+
honorific_pronouns_effective = bool(honorific_pronouns and genre_decision and genre_decision.is_classical)
|
| 937 |
+
honorific_kinship_effective = bool(honorific_kinship)
|
| 938 |
+
honorific_on_effective = honorific_kinship_effective or honorific_pronouns_effective
|
| 939 |
+
|
| 940 |
+
if honorific_on_effective and rows:
|
| 941 |
+
new_rows = []
|
| 942 |
+
for index, chunk_zh, translated_vi in rows:
|
| 943 |
+
fixed = normalize_honorifics(
|
| 944 |
+
chunk_zh, translated_vi,
|
| 945 |
+
apply_kinship=honorific_kinship_effective,
|
| 946 |
+
apply_pronouns=honorific_pronouns_effective,
|
| 947 |
+
classical_context=genre_decision.is_classical if genre_decision else None,
|
| 948 |
+
)
|
| 949 |
+
if fixed != translated_vi:
|
| 950 |
+
honorific_changed += 1
|
| 951 |
+
new_rows.append((index, chunk_zh, fixed))
|
| 952 |
+
rows = new_rows
|
| 953 |
+
|
| 954 |
+
if pronoun_harmonizer_v9 and rows:
|
| 955 |
+
rows, pronoun_report = harmonize_pronouns_v9(
|
| 956 |
+
rows,
|
| 957 |
+
route=v9_route_for_decision(genre_decision) if genre_decision else "unknown_copy_guard",
|
| 958 |
+
)
|
| 959 |
+
|
| 960 |
+
full_text = "\n".join(vi for _, _, vi in rows)
|
| 961 |
+
return rows, full_text, {
|
| 962 |
+
"genre_decision": genre_decision,
|
| 963 |
+
"honorific_changed": honorific_changed,
|
| 964 |
+
"honorific_pronouns_effective": honorific_pronouns_effective,
|
| 965 |
+
"honorific_kinship_effective": honorific_kinship_effective,
|
| 966 |
+
"pronoun_report": pronoun_report,
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
def _translate_run(
|
| 971 |
+
source: str,
|
| 972 |
+
model_key: str,
|
| 973 |
+
backend: str,
|
| 974 |
+
beam_size: float,
|
| 975 |
+
chunk_mode: str,
|
| 976 |
+
normalize_mode: str,
|
| 977 |
+
honorific_kinship: bool,
|
| 978 |
+
honorific_pronouns: bool,
|
| 979 |
+
pronoun_harmonizer_v9: bool,
|
| 980 |
+
auto_batch: bool,
|
| 981 |
+
manual_batch: float,
|
| 982 |
+
*,
|
| 983 |
+
filename_stem: str,
|
| 984 |
+
summary_prefix: str,
|
| 985 |
+
progress: gr.Progress = gr.Progress(),
|
| 986 |
+
) -> tuple:
|
| 987 |
+
try:
|
| 988 |
+
original_source = source
|
| 989 |
+
source = normalize_chinese_text(source, normalize_mode)
|
| 990 |
+
normalize_msg = normalization_message(original_source, source, normalize_mode)
|
| 991 |
+
honorific_kinship = bool(honorific_kinship)
|
| 992 |
+
honorific_pronouns = bool(honorific_pronouns)
|
| 993 |
+
honorific_on = honorific_kinship or honorific_pronouns
|
| 994 |
+
pronoun_harmonizer_v9 = bool(pronoun_harmonizer_v9)
|
| 995 |
+
|
| 996 |
+
if is_model_downloaded(model_key, backend):
|
| 997 |
+
load_msg = "Đang nạp model..."
|
| 998 |
+
else:
|
| 999 |
+
label = MODELS[model_key].label if model_key in MODELS else model_key
|
| 1000 |
+
load_msg = f"Đang tải model {label} từ Hugging Face (lần đầu, vui lòng đợi)..."
|
| 1001 |
+
set_progress(0, load_msg)
|
| 1002 |
+
progress(0, desc=load_msg)
|
| 1003 |
+
|
| 1004 |
+
resolve_batch_size(auto_batch, manual_batch)
|
| 1005 |
+
status = ensure_model(model_key, backend, beam_size)
|
| 1006 |
+
|
| 1007 |
+
set_progress(2, f"{normalize_msg} Đang chia chunk...")
|
| 1008 |
+
progress(0.02, desc="Đang chia chunk...")
|
| 1009 |
+
|
| 1010 |
+
rows: list[tuple[int, str, str]] = []
|
| 1011 |
+
full_text = ""
|
| 1012 |
+
last_progress_update = 0.0
|
| 1013 |
+
translate_start = time.perf_counter()
|
| 1014 |
+
|
| 1015 |
+
for done, total, message, result_rows, result_text in translator.translate_text_iter(
|
| 1016 |
+
source,
|
| 1017 |
+
chunk_mode=chunk_mode,
|
| 1018 |
+
beam_size=int(beam_size),
|
| 1019 |
+
):
|
| 1020 |
+
if result_rows is not None and result_text is not None:
|
| 1021 |
+
rows = result_rows
|
| 1022 |
+
full_text = result_text
|
| 1023 |
+
continue
|
| 1024 |
+
|
| 1025 |
+
now = time.perf_counter()
|
| 1026 |
+
should_update_progress = (
|
| 1027 |
+
done == 0
|
| 1028 |
+
or done == total
|
| 1029 |
+
or now - last_progress_update >= PROGRESS_UPDATE_SECONDS
|
| 1030 |
+
)
|
| 1031 |
+
if not should_update_progress:
|
| 1032 |
+
continue
|
| 1033 |
+
|
| 1034 |
+
pct = round(done / max(total, 1) * 100, 1)
|
| 1035 |
+
detail = f"{message} ({pct}%)"
|
| 1036 |
+
set_progress(pct, detail)
|
| 1037 |
+
progress(done / max(total, 1), desc=message)
|
| 1038 |
+
last_progress_update = now
|
| 1039 |
+
|
| 1040 |
+
translate_seconds = time.perf_counter() - translate_start
|
| 1041 |
+
|
| 1042 |
+
rows, full_text, postprocess_report = apply_postprocess_rows(
|
| 1043 |
+
rows,
|
| 1044 |
+
honorific_kinship=honorific_kinship,
|
| 1045 |
+
honorific_pronouns=honorific_pronouns,
|
| 1046 |
+
pronoun_harmonizer_v9=pronoun_harmonizer_v9,
|
| 1047 |
+
)
|
| 1048 |
+
genre_decision = postprocess_report.get("genre_decision")
|
| 1049 |
+
honorific_changed = int(postprocess_report.get("honorific_changed") or 0)
|
| 1050 |
+
honorific_pronouns_effective = bool(postprocess_report.get("honorific_pronouns_effective"))
|
| 1051 |
+
pronoun_report = postprocess_report.get("pronoun_report") or {}
|
| 1052 |
+
|
| 1053 |
+
set_progress(98, "Đang lưu file xuất...")
|
| 1054 |
+
progress(0.98, desc="Đang lưu file xuất...")
|
| 1055 |
+
|
| 1056 |
+
download_path = export_translation(full_text, filename_stem)
|
| 1057 |
+
duration = _format_duration(translate_seconds)
|
| 1058 |
+
rate = len(rows) / translate_seconds if translate_seconds > 0 else 0.0
|
| 1059 |
+
time_note = f"⏱ {duration}"
|
| 1060 |
+
if rate >= 1:
|
| 1061 |
+
time_note += f" ({rate:.0f} chunk/giây)"
|
| 1062 |
+
honorific_note = ""
|
| 1063 |
+
if honorific_on:
|
| 1064 |
+
parts = []
|
| 1065 |
+
if honorific_kinship:
|
| 1066 |
+
parts.append("thân tộc")
|
| 1067 |
+
if honorific_pronouns:
|
| 1068 |
+
parts.append("đại từ" if honorific_pronouns_effective else "đại từ bỏ qua")
|
| 1069 |
+
honorific_note = (f" Đã chuẩn hóa xưng hô ({' + '.join(parts)}): "
|
| 1070 |
+
f"{honorific_changed} chunk chỉnh.")
|
| 1071 |
+
pronoun_note = ""
|
| 1072 |
+
if pronoun_harmonizer_v9:
|
| 1073 |
+
pronoun_note = (
|
| 1074 |
+
" Ổn định ngôi xưng V9: "
|
| 1075 |
+
f"{pronoun_report.get('changed_rows', 0)} chunk chỉnh, "
|
| 1076 |
+
f"route `{pronoun_report.get('route', 'n/a')}`."
|
| 1077 |
+
)
|
| 1078 |
+
route_note = f" Route hậu kỳ `{genre_decision.route}`." if genre_decision and (honorific_on or pronoun_harmonizer_v9) else ""
|
| 1079 |
+
summary = f"{summary_prefix} **{len(rows)}** chunk · {time_note}. {normalize_msg}{route_note}{honorific_note}{pronoun_note}"
|
| 1080 |
+
status = f"{status} · {time_note}"
|
| 1081 |
+
finish_progress(f"Hoàn tất — {len(rows)} chunk trong {duration} (100%)")
|
| 1082 |
+
progress(1.0, desc="Hoàn tất")
|
| 1083 |
+
|
| 1084 |
+
return _build_results(rows, full_text, status, summary, download_path)
|
| 1085 |
+
except Exception as exc:
|
| 1086 |
+
reset_progress(f"Lỗi: {_exception_message(exc)}")
|
| 1087 |
+
raise
|
| 1088 |
+
|
| 1089 |
+
|
| 1090 |
+
def translate_text_ui(
|
| 1091 |
+
source: str,
|
| 1092 |
+
model_key: str,
|
| 1093 |
+
backend: str,
|
| 1094 |
+
beam_size: float,
|
| 1095 |
+
chunk_mode: str,
|
| 1096 |
+
normalize_mode: str,
|
| 1097 |
+
honorific_kinship: bool,
|
| 1098 |
+
honorific_pronouns: bool,
|
| 1099 |
+
pronoun_harmonizer_v9: bool,
|
| 1100 |
+
auto_batch: bool,
|
| 1101 |
+
manual_batch: float,
|
| 1102 |
+
progress: gr.Progress = gr.Progress(),
|
| 1103 |
+
) -> tuple:
|
| 1104 |
+
if not source.strip():
|
| 1105 |
+
raise gr.Error("Nhập văn bản tiếng Trung cần dịch.")
|
| 1106 |
+
|
| 1107 |
+
return _translate_run(
|
| 1108 |
+
source,
|
| 1109 |
+
model_key,
|
| 1110 |
+
backend,
|
| 1111 |
+
beam_size,
|
| 1112 |
+
chunk_mode,
|
| 1113 |
+
normalize_mode,
|
| 1114 |
+
honorific_kinship,
|
| 1115 |
+
honorific_pronouns,
|
| 1116 |
+
pronoun_harmonizer_v9,
|
| 1117 |
+
auto_batch,
|
| 1118 |
+
manual_batch,
|
| 1119 |
+
filename_stem="hachimimt",
|
| 1120 |
+
summary_prefix="Đã dịch",
|
| 1121 |
+
progress=progress,
|
| 1122 |
+
)
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
def translate_file_ui(
|
| 1126 |
+
file_obj,
|
| 1127 |
+
model_key: str,
|
| 1128 |
+
backend: str,
|
| 1129 |
+
beam_size: float,
|
| 1130 |
+
chunk_mode: str,
|
| 1131 |
+
normalize_mode: str,
|
| 1132 |
+
honorific_kinship: bool,
|
| 1133 |
+
honorific_pronouns: bool,
|
| 1134 |
+
pronoun_harmonizer_v9: bool,
|
| 1135 |
+
auto_batch: bool,
|
| 1136 |
+
manual_batch: float,
|
| 1137 |
+
progress: gr.Progress = gr.Progress(),
|
| 1138 |
+
) -> tuple:
|
| 1139 |
+
if file_obj is None:
|
| 1140 |
+
raise gr.Error("Chọn file .txt cần dịch.")
|
| 1141 |
+
|
| 1142 |
+
path = Path(file_obj)
|
| 1143 |
+
if path.suffix.lower() != ".txt":
|
| 1144 |
+
raise gr.Error("Chỉ hỗ tr��� file .txt")
|
| 1145 |
+
|
| 1146 |
+
source = read_text_file(path)
|
| 1147 |
+
if not source.strip():
|
| 1148 |
+
raise gr.Error("File trống.")
|
| 1149 |
+
|
| 1150 |
+
return _translate_run(
|
| 1151 |
+
source,
|
| 1152 |
+
model_key,
|
| 1153 |
+
backend,
|
| 1154 |
+
beam_size,
|
| 1155 |
+
chunk_mode,
|
| 1156 |
+
normalize_mode,
|
| 1157 |
+
honorific_kinship,
|
| 1158 |
+
honorific_pronouns,
|
| 1159 |
+
pronoun_harmonizer_v9,
|
| 1160 |
+
auto_batch,
|
| 1161 |
+
manual_batch,
|
| 1162 |
+
filename_stem=path.stem,
|
| 1163 |
+
summary_prefix=f"Đã dịch từ `{path.name}` —",
|
| 1164 |
+
progress=progress,
|
| 1165 |
+
)
|
| 1166 |
+
|
| 1167 |
+
|
| 1168 |
+
def build_ui() -> gr.Blocks:
|
| 1169 |
+
model_choices = [(cfg.label, key) for key, cfg in MODELS.items()]
|
| 1170 |
+
|
| 1171 |
+
with gr.Blocks(title="HachimiMT — Dịch Trung Việt") as demo:
|
| 1172 |
+
# ── Header ────────────────────────────────────────────────────
|
| 1173 |
+
with gr.Column(elem_id="app-header"):
|
| 1174 |
+
gr.Markdown("# HachimiMT", elem_id="app-title")
|
| 1175 |
+
gr.HTML('<div id="app-rule"></div>')
|
| 1176 |
+
|
| 1177 |
+
# ── Khu cấu hình ──────────────────────────────────────────────
|
| 1178 |
+
with gr.Group(elem_id="settings-card"):
|
| 1179 |
+
gr.Markdown("Cấu hình dịch", elem_classes=["section-label"])
|
| 1180 |
+
with gr.Row():
|
| 1181 |
+
model_select = gr.Dropdown(
|
| 1182 |
+
choices=model_choices, value="HachimiMT-60", label="Model dịch", scale=3,
|
| 1183 |
+
)
|
| 1184 |
+
backend_select = gr.Radio(
|
| 1185 |
+
[("CTranslate2 — nhanh", Backend.CT2.value), ("PyTorch", Backend.TRANSFORMERS.value)],
|
| 1186 |
+
value=Backend.CT2.value,
|
| 1187 |
+
label="Engine",
|
| 1188 |
+
scale=3,
|
| 1189 |
+
)
|
| 1190 |
+
beam_size = gr.Slider(1, 4, value=2, step=1, label="Beam (1–4)", scale=2)
|
| 1191 |
+
chunk_mode = gr.Radio(
|
| 1192 |
+
[("Theo câu", "sentence"), ("Theo đoạn", "paragraph")],
|
| 1193 |
+
value="sentence",
|
| 1194 |
+
label="Chia chunk",
|
| 1195 |
+
scale=2,
|
| 1196 |
+
)
|
| 1197 |
+
normalize_mode = gr.Dropdown(
|
| 1198 |
+
[
|
| 1199 |
+
("Tự động phồn → giản", NORMALIZE_AUTO),
|
| 1200 |
+
("Ép phồn → giản", NORMALIZE_T2S),
|
| 1201 |
+
("Giữ nguyên", NORMALIZE_NONE),
|
| 1202 |
+
],
|
| 1203 |
+
value=NORMALIZE_AUTO,
|
| 1204 |
+
label="Chuẩn hóa chữ Hán",
|
| 1205 |
+
scale=2,
|
| 1206 |
+
)
|
| 1207 |
+
with gr.Row():
|
| 1208 |
+
auto_batch = gr.Checkbox(value=True, label="Tự động batch theo CPU/GPU", scale=2)
|
| 1209 |
+
manual_batch = gr.Slider(
|
| 1210 |
+
4, 128, value=HW_PROFILE.batch_size, step=4,
|
| 1211 |
+
label="Batch size (chunk/lần)", interactive=False, scale=3,
|
| 1212 |
+
)
|
| 1213 |
+
|
| 1214 |
+
model_badge = gr.HTML(
|
| 1215 |
+
render_model_badge("HachimiMT-60", Backend.CT2.value),
|
| 1216 |
+
elem_id="model-badge",
|
| 1217 |
+
)
|
| 1218 |
+
engine_hint = gr.Markdown(_engine_hint_text(Backend.CT2.value))
|
| 1219 |
+
status = gr.Textbox(
|
| 1220 |
+
label="Trạng thái", value="Sẵn sàng — chọn cấu hình rồi bắt đầu dịch.",
|
| 1221 |
+
interactive=False, elem_classes=["status-box"],
|
| 1222 |
+
)
|
| 1223 |
+
|
| 1224 |
+
_gpu_idle = gpu_available_but_idle()
|
| 1225 |
+
with gr.Group(visible=_gpu_idle, elem_id="gpu-hint-box") as gpu_hint_group:
|
| 1226 |
+
gpu_hint = gr.HTML(render_gpu_hint_html())
|
| 1227 |
+
gpu_install_btn = gr.Button(
|
| 1228 |
+
"⚡ Cài torch để bật GPU (tải ~2–3 GB, cần ~5 GB trống)",
|
| 1229 |
+
variant="primary",
|
| 1230 |
+
)
|
| 1231 |
+
gpu_install_log = gr.Textbox(
|
| 1232 |
+
label="Tiến trình cài đặt",
|
| 1233 |
+
value="",
|
| 1234 |
+
interactive=False,
|
| 1235 |
+
lines=6,
|
| 1236 |
+
visible=False,
|
| 1237 |
+
elem_classes=["status-box"],
|
| 1238 |
+
)
|
| 1239 |
+
|
| 1240 |
+
with gr.Accordion("Tuỳ chọn chuẩn hóa xưng hô (nâng cao)", open=False,
|
| 1241 |
+
elem_id="honorific-accordion"):
|
| 1242 |
+
with gr.Row():
|
| 1243 |
+
honorific_kinship = gr.Checkbox(
|
| 1244 |
+
value=False, label="Thân tộc (tỷ / muội / ca ca…)", scale=1,
|
| 1245 |
+
)
|
| 1246 |
+
honorific_pronouns = gr.Checkbox(
|
| 1247 |
+
value=False, label="Đại từ (ngươi / hắn / nàng / ta)", scale=1,
|
| 1248 |
+
)
|
| 1249 |
+
pronoun_harmonizer_v9 = gr.Checkbox(
|
| 1250 |
+
value=False, label="Ổn định ngôi hiện đại", scale=1,
|
| 1251 |
+
)
|
| 1252 |
+
gr.Markdown(
|
| 1253 |
+
"Ép xưng hô về Hán-Việt khi nguồn có từ tương ứng · **Đại từ** chỉ áp "
|
| 1254 |
+
"khi route cấp chương là văn cổ trang · **Ổn định ngôi hiện đại** chỉ "
|
| 1255 |
+
"rewrite khi route hiện đại, ví dụ thầy/em, mẹ/con, anh/em. Mixed/unknown "
|
| 1256 |
+
"sẽ guard để tránh sửa quá tay.",
|
| 1257 |
+
elem_classes=["honorific-hint"],
|
| 1258 |
+
)
|
| 1259 |
+
|
| 1260 |
+
with gr.Accordion("Thông tin máy & hướng dẫn", open=False):
|
| 1261 |
+
gr.Markdown(
|
| 1262 |
+
f"**Cấu hình tự động:** {HW_PROFILE.summary}\n\n"
|
| 1263 |
+
f"**GPU inference:** {HW_PROFILE.gpu_name or 'CPU'}\n\n"
|
| 1264 |
+
f"**Khởi động / tắt:** `start.bat` / `stop.bat` · http://127.0.0.1:{APP_PORT}",
|
| 1265 |
+
elem_classes=["info-card"],
|
| 1266 |
+
)
|
| 1267 |
+
|
| 1268 |
+
model_select.change(
|
| 1269 |
+
on_model_change,
|
| 1270 |
+
inputs=[model_select, backend_select],
|
| 1271 |
+
outputs=[beam_size, model_badge],
|
| 1272 |
+
)
|
| 1273 |
+
backend_select.change(
|
| 1274 |
+
on_backend_change,
|
| 1275 |
+
inputs=[backend_select, model_select],
|
| 1276 |
+
outputs=[engine_hint, model_badge],
|
| 1277 |
+
)
|
| 1278 |
+
auto_batch.change(on_auto_batch_toggle, inputs=[auto_batch], outputs=[manual_batch])
|
| 1279 |
+
|
| 1280 |
+
gpu_install_btn.click(
|
| 1281 |
+
lambda: gr.update(visible=True),
|
| 1282 |
+
outputs=[gpu_install_log],
|
| 1283 |
+
).then(
|
| 1284 |
+
install_gpu_torch_ui,
|
| 1285 |
+
outputs=[gpu_install_btn, gpu_install_log],
|
| 1286 |
+
)
|
| 1287 |
+
|
| 1288 |
+
# ── Nhập liệu (văn bản / file) ────────────────────────────────
|
| 1289 |
+
with gr.Tabs():
|
| 1290 |
+
with gr.Tab("📝 Dịch văn bản"):
|
| 1291 |
+
source = gr.Textbox(
|
| 1292 |
+
label="Văn bản gốc (Tiếng Trung)",
|
| 1293 |
+
placeholder="粘贴中文原文…",
|
| 1294 |
+
lines=10,
|
| 1295 |
+
elem_classes=["source-input"],
|
| 1296 |
+
)
|
| 1297 |
+
text_btn = gr.Button("Dịch văn bản", variant="primary", size="lg")
|
| 1298 |
+
|
| 1299 |
+
with gr.Tab("📄 Dịch file .txt"):
|
| 1300 |
+
gr.Markdown("Upload file `.txt` tiếng Trung → dịch toàn bộ → tải file `.txt` tiếng Việt.")
|
| 1301 |
+
file_input = gr.File(label="Chọn file .txt đầu vào", file_types=[".txt"], type="filepath")
|
| 1302 |
+
file_preview = gr.Textbox(
|
| 1303 |
+
label="Xem trước nội dung file", lines=8, interactive=False, elem_classes=["file-preview"],
|
| 1304 |
+
)
|
| 1305 |
+
file_btn = gr.Button("Dịch file & xuất .txt", variant="primary", size="lg")
|
| 1306 |
+
|
| 1307 |
+
def preview_file(file_obj) -> str:
|
| 1308 |
+
if not file_obj:
|
| 1309 |
+
return ""
|
| 1310 |
+
return read_text_file(Path(file_obj), max_chars=8000)
|
| 1311 |
+
|
| 1312 |
+
file_input.change(preview_file, inputs=[file_input], outputs=[file_preview])
|
| 1313 |
+
|
| 1314 |
+
# ── Tiến trình (một thanh duy nhất) ───────────────────────────
|
| 1315 |
+
progress_html = gr.HTML(render_progress_html(0, "Sẵn sàng.", False))
|
| 1316 |
+
|
| 1317 |
+
# ── Kết quả ───────────────────────────────────────────────────
|
| 1318 |
+
result_summary = gr.Markdown(elem_id="result-summary")
|
| 1319 |
+
|
| 1320 |
+
with gr.Accordion("Đối chiếu song song (câu gốc · bản dịch)", open=True):
|
| 1321 |
+
compare_view = gr.HTML(render_compare_html([]), elem_id="compare-view")
|
| 1322 |
+
|
| 1323 |
+
with gr.Accordion("Bản dịch đầy đủ & xuất file", open=True):
|
| 1324 |
+
full_output = gr.Textbox(
|
| 1325 |
+
label="Bản dịch đầy đủ", lines=10, interactive=False,
|
| 1326 |
+
elem_id="full-output",
|
| 1327 |
+
)
|
| 1328 |
+
# Giữ bản dịch ĐẦY ĐỦ (không bị cap hiển thị) để nút Xuất .txt dùng.
|
| 1329 |
+
full_text_state = gr.State("")
|
| 1330 |
+
with gr.Row():
|
| 1331 |
+
export_btn = gr.Button("💾 Xuất bản dịch .txt", variant="secondary")
|
| 1332 |
+
download_file = gr.File(label="Tải file bản dịch (.txt)")
|
| 1333 |
+
|
| 1334 |
+
result_outputs = [
|
| 1335 |
+
compare_view,
|
| 1336 |
+
full_output,
|
| 1337 |
+
status,
|
| 1338 |
+
download_file,
|
| 1339 |
+
result_summary,
|
| 1340 |
+
full_text_state,
|
| 1341 |
+
]
|
| 1342 |
+
|
| 1343 |
+
translate_inputs = [
|
| 1344 |
+
model_select, backend_select, beam_size, chunk_mode, normalize_mode,
|
| 1345 |
+
honorific_kinship, honorific_pronouns, pronoun_harmonizer_v9,
|
| 1346 |
+
auto_batch, manual_batch,
|
| 1347 |
+
]
|
| 1348 |
+
|
| 1349 |
+
text_btn.click(
|
| 1350 |
+
translate_text_ui,
|
| 1351 |
+
inputs=[source, *translate_inputs],
|
| 1352 |
+
outputs=result_outputs,
|
| 1353 |
+
concurrency_limit=1,
|
| 1354 |
+
concurrency_id="translate",
|
| 1355 |
+
)
|
| 1356 |
+
|
| 1357 |
+
file_btn.click(
|
| 1358 |
+
translate_file_ui,
|
| 1359 |
+
inputs=[file_input, *translate_inputs],
|
| 1360 |
+
outputs=result_outputs,
|
| 1361 |
+
concurrency_limit=1,
|
| 1362 |
+
concurrency_id="translate",
|
| 1363 |
+
)
|
| 1364 |
+
|
| 1365 |
+
export_btn.click(
|
| 1366 |
+
lambda text: export_translation(text, "hachimimt"),
|
| 1367 |
+
inputs=[full_text_state], outputs=[download_file],
|
| 1368 |
+
)
|
| 1369 |
+
|
| 1370 |
+
progress_timer = gr.Timer(0.3, active=True)
|
| 1371 |
+
progress_timer.tick(
|
| 1372 |
+
poll_progress_ui,
|
| 1373 |
+
outputs=[progress_html],
|
| 1374 |
+
show_progress=False,
|
| 1375 |
+
)
|
| 1376 |
+
|
| 1377 |
+
return demo
|
| 1378 |
+
|
| 1379 |
+
|
| 1380 |
+
def main() -> None:
|
| 1381 |
+
if not IS_HF_SPACE: # Space: process do HF quản, không cần PID
|
| 1382 |
+
write_pid_file()
|
| 1383 |
+
atexit.register(remove_pid_file)
|
| 1384 |
+
reset_progress()
|
| 1385 |
+
|
| 1386 |
+
demo = build_ui()
|
| 1387 |
+
demo.queue(default_concurrency_limit=8)
|
| 1388 |
+
# Gradio 6: theme/css/head truyền ở launch() (không còn ở Blocks()).
|
| 1389 |
+
favicon = Path(__file__).resolve().parent / "assets" / "favicon.svg"
|
| 1390 |
+
launch_kwargs = dict(
|
| 1391 |
+
theme=gr.themes.Soft(primary_hue="orange", neutral_hue="stone"),
|
| 1392 |
+
css=CUSTOM_CSS,
|
| 1393 |
+
head=HEAD_HTML,
|
| 1394 |
+
favicon_path=str(favicon) if favicon.exists() else None,
|
| 1395 |
+
allowed_paths=[str(EXPORTS_DIR), tempfile.gettempdir()],
|
| 1396 |
+
)
|
| 1397 |
+
if not IS_HF_SPACE: # local: bind localhost cố định; Space: HF tự lo
|
| 1398 |
+
launch_kwargs["server_name"] = "127.0.0.1"
|
| 1399 |
+
launch_kwargs["server_port"] = APP_PORT
|
| 1400 |
+
demo.launch(**launch_kwargs)
|
| 1401 |
+
|
| 1402 |
+
|
| 1403 |
+
if __name__ == "__main__":
|
| 1404 |
+
main()
|
src/assets/favicon.svg
ADDED
|
|
src/chunker.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Split Chinese source text into translation chunks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
SENTENCE_END = re.compile(r"(?<=[。!?!?;;…])")
|
| 8 |
+
PARAGRAPH_BREAK = re.compile(r"\n\s*\n+")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def split_chunks(text: str, mode: str = "sentence") -> list[str]:
|
| 12 |
+
"""Split *text* into non-empty chunks for independent translation."""
|
| 13 |
+
text = text.strip()
|
| 14 |
+
if not text:
|
| 15 |
+
return []
|
| 16 |
+
|
| 17 |
+
if mode == "paragraph":
|
| 18 |
+
parts = PARAGRAPH_BREAK.split(text)
|
| 19 |
+
else:
|
| 20 |
+
parts: list[str] = []
|
| 21 |
+
for paragraph in text.splitlines():
|
| 22 |
+
paragraph = paragraph.strip()
|
| 23 |
+
if not paragraph:
|
| 24 |
+
continue
|
| 25 |
+
parts.extend(SENTENCE_END.split(paragraph))
|
| 26 |
+
|
| 27 |
+
return [chunk.strip() for chunk in parts if chunk.strip()]
|
src/gpu_setup.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cài torch bản CUDA phù hợp để bật GPU cho CTranslate2.
|
| 2 |
+
|
| 3 |
+
Engine mặc định (CT2) cần thư viện cuBLAS/cuDNN để chạy GPU; cách đơn giản nhất
|
| 4 |
+
là cài bản torch CUDA (đã đóng gói sẵn các DLL đó). Module này:
|
| 5 |
+
- chọn channel cu1xx cao nhất mà driver hỗ trợ,
|
| 6 |
+
- chạy `pip install torch --index-url ...` vào CHÍNH python đang chạy (sys.executable),
|
| 7 |
+
- stream log để UI hiển thị tiến trình.
|
| 8 |
+
|
| 9 |
+
Sau khi cài xong PHẢI khởi động lại app: torch phải có mặt TRƯỚC khi import
|
| 10 |
+
ctranslate2 (xem hardware._guard_ct2_cuda_before_import) thì GPU mới được bật.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import subprocess
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
from typing import Callable, Iterator
|
| 19 |
+
|
| 20 |
+
# Các channel CUDA mà PyTorch stable phát hành (cao → thấp). Xác minh tại
|
| 21 |
+
# https://pytorch.org/get-started/locally/ (hiện: cu118, cu126, cu128).
|
| 22 |
+
# (major, minor, "cuXXX")
|
| 23 |
+
_TORCH_CUDA_CHANNELS = [
|
| 24 |
+
(12, 8, "cu128"),
|
| 25 |
+
(12, 6, "cu126"),
|
| 26 |
+
(11, 8, "cu118"),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def choose_cuda_channel(driver_cuda: str | None) -> str | None:
|
| 31 |
+
"""Chọn channel torch cao nhất mà driver còn hỗ trợ (driver_cuda dạng '13.2').
|
| 32 |
+
|
| 33 |
+
NVIDIA backward-compatible: driver hỗ trợ tới CUDA X chạy được mọi wheel <= X.
|
| 34 |
+
Trả None nếu driver quá cũ hơn cả bản thấp nhất (cu118).
|
| 35 |
+
"""
|
| 36 |
+
if not driver_cuda:
|
| 37 |
+
# Không biết driver → chọn bản phổ biến tương thích rộng nhất.
|
| 38 |
+
return "cu118"
|
| 39 |
+
try:
|
| 40 |
+
major, minor = (int(part) for part in driver_cuda.split(".")[:2])
|
| 41 |
+
except (ValueError, TypeError):
|
| 42 |
+
return "cu118"
|
| 43 |
+
for ch_major, ch_minor, channel in _TORCH_CUDA_CHANNELS:
|
| 44 |
+
if (major, minor) >= (ch_major, ch_minor):
|
| 45 |
+
return channel
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def torch_install_command(channel: str) -> list[str]:
|
| 50 |
+
# --upgrade --force-reinstall: BẮT BUỘC. Nếu user đã có torch-CPU (vd từ setup
|
| 51 |
+
# cũ / requirements-pytorch), `pip install torch` thường báo "already satisfied"
|
| 52 |
+
# và KHÔNG ghi đè → cài xong vẫn là CPU. Force-reinstall đảm bảo lấy bản CUDA.
|
| 53 |
+
return [
|
| 54 |
+
sys.executable,
|
| 55 |
+
"-m",
|
| 56 |
+
"pip",
|
| 57 |
+
"install",
|
| 58 |
+
"--upgrade",
|
| 59 |
+
"--force-reinstall",
|
| 60 |
+
"torch",
|
| 61 |
+
"--index-url",
|
| 62 |
+
f"https://download.pytorch.org/whl/{channel}",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def verify_torch_cuda() -> tuple[bool, str]:
|
| 67 |
+
"""Sau khi cài, kiểm tra torch có thật sự thấy CUDA không (subprocess sạch).
|
| 68 |
+
|
| 69 |
+
Chạy trong tiến trình con KHÔNG bị mask CUDA_VISIBLE_DEVICES=-1 (guard của app
|
| 70 |
+
có thể đã set ở tiến trình hiện tại). Bắt trường hợp 'cài xong nhưng vẫn CPU'.
|
| 71 |
+
"""
|
| 72 |
+
code = (
|
| 73 |
+
"import torch,sys;"
|
| 74 |
+
"print('TORCH_VERSION='+torch.__version__);"
|
| 75 |
+
"print('CUDA_OK='+str(torch.cuda.is_available()))"
|
| 76 |
+
)
|
| 77 |
+
env = dict(os.environ)
|
| 78 |
+
env.pop("CUDA_VISIBLE_DEVICES", None) # bỏ mask để torch nhìn thấy GPU thật
|
| 79 |
+
try:
|
| 80 |
+
result = subprocess.run(
|
| 81 |
+
[sys.executable, "-c", code],
|
| 82 |
+
capture_output=True,
|
| 83 |
+
text=True,
|
| 84 |
+
timeout=120,
|
| 85 |
+
env=env,
|
| 86 |
+
)
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
return False, f"Không kiểm tra được torch sau cài: {exc}"
|
| 89 |
+
out = result.stdout
|
| 90 |
+
version = ""
|
| 91 |
+
for line in out.splitlines():
|
| 92 |
+
if line.startswith("TORCH_VERSION="):
|
| 93 |
+
version = line.split("=", 1)[1]
|
| 94 |
+
cuda_ok = "CUDA_OK=True" in out
|
| 95 |
+
if cuda_ok:
|
| 96 |
+
return True, f"torch {version} đã nhận GPU."
|
| 97 |
+
return False, (
|
| 98 |
+
f"Đã cài torch {version or '(?)'} nhưng torch.cuda vẫn = False — "
|
| 99 |
+
"có thể driver chưa phù hợp hoặc bản torch không khớp. Xem README."
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _stream_pip(cmd: list[str]) -> Iterator[str]:
|
| 104 |
+
"""Chạy pip, yield từng dòng output (cả stdout/stderr gộp)."""
|
| 105 |
+
proc = subprocess.Popen(
|
| 106 |
+
cmd,
|
| 107 |
+
stdout=subprocess.PIPE,
|
| 108 |
+
stderr=subprocess.STDOUT,
|
| 109 |
+
text=True,
|
| 110 |
+
bufsize=1,
|
| 111 |
+
)
|
| 112 |
+
assert proc.stdout is not None
|
| 113 |
+
for line in proc.stdout:
|
| 114 |
+
yield line.rstrip()
|
| 115 |
+
proc.wait()
|
| 116 |
+
yield f"__EXIT__:{proc.returncode}"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def install_torch_cuda(
|
| 120 |
+
driver_cuda: str | None,
|
| 121 |
+
on_log: Callable[[str], None] | None = None,
|
| 122 |
+
) -> tuple[bool, str]:
|
| 123 |
+
"""Cài torch CUDA vào env hiện tại. Trả (thành công, thông điệp cuối).
|
| 124 |
+
|
| 125 |
+
on_log nhận từng dòng log (để UI cập nhật). Đây là hàm blocking — gọi trong
|
| 126 |
+
thread/generator của Gradio, đừng gọi thẳng trên event loop chính.
|
| 127 |
+
"""
|
| 128 |
+
channel = choose_cuda_channel(driver_cuda)
|
| 129 |
+
if channel is None:
|
| 130 |
+
return False, (
|
| 131 |
+
"Driver NVIDIA quá cũ so với các bản torch CUDA hiện có. "
|
| 132 |
+
"Hãy cập nhật driver rồi thử lại, hoặc cài torch thủ công."
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
cmd = torch_install_command(channel)
|
| 136 |
+
if on_log:
|
| 137 |
+
on_log(f"Cài torch CUDA ({channel}) — tải ~2–3 GB, cần ~5 GB ổ trống, vui lòng đợi…")
|
| 138 |
+
on_log(" ".join(cmd))
|
| 139 |
+
|
| 140 |
+
exit_code: int | None = None
|
| 141 |
+
for line in _stream_pip(cmd):
|
| 142 |
+
if line.startswith("__EXIT__:"):
|
| 143 |
+
exit_code = int(line.split(":", 1)[1])
|
| 144 |
+
continue
|
| 145 |
+
if on_log and line:
|
| 146 |
+
on_log(line)
|
| 147 |
+
|
| 148 |
+
if exit_code != 0:
|
| 149 |
+
return False, (
|
| 150 |
+
f"Cài torch thất bại (mã lỗi {exit_code}). "
|
| 151 |
+
"Kiểm tra mạng/dung lượng đĩa, hoặc cài thủ công theo README."
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# Cài xong chưa đủ — xác minh torch THẬT SỰ thấy CUDA (bắt 'already satisfied'
|
| 155 |
+
# hoặc bản không khớp driver).
|
| 156 |
+
ok, verify_msg = verify_torch_cuda()
|
| 157 |
+
if ok:
|
| 158 |
+
return True, (
|
| 159 |
+
f"Đã cài torch CUDA ({channel}) — {verify_msg} "
|
| 160 |
+
"Hãy TẮT và MỞ LẠI app (stop rồi start) để bật GPU."
|
| 161 |
+
)
|
| 162 |
+
return False, verify_msg
|
src/hardware.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auto-detect CPU/GPU and recommend CT2 batch + thread settings."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import shutil
|
| 8 |
+
import subprocess
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
|
| 12 |
+
# Phát hiện GPU NVIDIA vật lý qua nvidia-smi TRƯỚC khi (có thể) mask CUDA.
|
| 13 |
+
# Cần làm trước vì sau khi set CUDA_VISIBLE_DEVICES=-1 thì cả ct2 lẫn torch đều
|
| 14 |
+
# không thấy GPU nữa — UI sẽ không biết "máy có GPU nhưng đang chạy CPU".
|
| 15 |
+
PHYSICAL_NVIDIA_GPU = False # máy có card NVIDIA thật?
|
| 16 |
+
PHYSICAL_GPU_NAME: str | None = None
|
| 17 |
+
DRIVER_CUDA_VERSION: str | None = None # CUDA tối đa driver hỗ trợ, vd "13.2"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _detect_nvidia_gpu() -> None:
|
| 21 |
+
"""Chạy nvidia-smi để biết có GPU NVIDIA + CUDA version tối đa của driver."""
|
| 22 |
+
global PHYSICAL_NVIDIA_GPU, PHYSICAL_GPU_NAME, DRIVER_CUDA_VERSION
|
| 23 |
+
if shutil.which("nvidia-smi") is None:
|
| 24 |
+
return
|
| 25 |
+
try:
|
| 26 |
+
# Bảng nvidia-smi chứa "CUDA Version: X.Y" ở header.
|
| 27 |
+
header = subprocess.run(
|
| 28 |
+
["nvidia-smi"],
|
| 29 |
+
capture_output=True,
|
| 30 |
+
text=True,
|
| 31 |
+
timeout=10,
|
| 32 |
+
)
|
| 33 |
+
name = subprocess.run(
|
| 34 |
+
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
|
| 35 |
+
capture_output=True,
|
| 36 |
+
text=True,
|
| 37 |
+
timeout=10,
|
| 38 |
+
)
|
| 39 |
+
except Exception:
|
| 40 |
+
return
|
| 41 |
+
if name.returncode == 0 and name.stdout.strip():
|
| 42 |
+
PHYSICAL_NVIDIA_GPU = True
|
| 43 |
+
PHYSICAL_GPU_NAME = name.stdout.strip().splitlines()[0].strip()
|
| 44 |
+
if header.returncode == 0:
|
| 45 |
+
match = re.search(r"CUDA Version:\s*([0-9]+\.[0-9]+)", header.stdout)
|
| 46 |
+
if match:
|
| 47 |
+
DRIVER_CUDA_VERSION = match.group(1)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _torch_cuda_usable() -> bool:
|
| 51 |
+
try:
|
| 52 |
+
import torch
|
| 53 |
+
|
| 54 |
+
return bool(torch.cuda.is_available())
|
| 55 |
+
except Exception:
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _guard_ct2_cuda_before_import() -> None:
|
| 60 |
+
"""Chặn CTranslate2 dò CUDA khi máy có GPU NVIDIA nhưng KHÔNG có torch-CUDA.
|
| 61 |
+
|
| 62 |
+
CTranslate2 (wheel pip) tự phát hiện CUDA độc lập với torch. Nếu máy có GPU
|
| 63 |
+
NVIDIA, nó sẽ cố nạp cuBLAS lúc translate_batch — nhưng cuBLAS DLL thường do
|
| 64 |
+
bản torch-CUDA cung cấp. Engine mặc định của app KHÔNG cài torch, nên nhóm
|
| 65 |
+
"có GPU + không torch-CUDA" sẽ crash 'cublas64_12.dll not found'.
|
| 66 |
+
|
| 67 |
+
CTranslate2 đọc CUDA_VISIBLE_DEVICES MỘT LẦN lúc init, nên phải set TRƯỚC khi
|
| 68 |
+
`import ctranslate2`. Chỉ ép CPU khi không có torch-CUDA khả dụng; người dùng
|
| 69 |
+
torch-CUDA giữ nguyên GPU (cuBLAS của họ do torch cấp).
|
| 70 |
+
"""
|
| 71 |
+
if os.environ.get("CUDA_VISIBLE_DEVICES") is not None:
|
| 72 |
+
return # tôn trọng lựa chọn của người dùng
|
| 73 |
+
if os.environ.get("HACHIMIMT_FORCE_CT2_CUDA", "").strip() == "1":
|
| 74 |
+
return # cho phép tự chịu trách nhiệm bật CUDA cho CT2
|
| 75 |
+
if not _torch_cuda_usable():
|
| 76 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
_detect_nvidia_gpu()
|
| 80 |
+
_guard_ct2_cuda_before_import()
|
| 81 |
+
|
| 82 |
+
import ctranslate2
|
| 83 |
+
|
| 84 |
+
BATCH_MIN = 4
|
| 85 |
+
BATCH_MAX = 128
|
| 86 |
+
THREAD_MIN = 1
|
| 87 |
+
THREAD_MAX = 16
|
| 88 |
+
TOKENIZE_WORKERS_MAX = 16
|
| 89 |
+
TOKENIZE_WORKERS_MIN = 1
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass(frozen=True)
|
| 93 |
+
class HardwareProfile:
|
| 94 |
+
cpu_logical: int
|
| 95 |
+
has_cuda: bool
|
| 96 |
+
gpu_name: str | None
|
| 97 |
+
vram_gb: float | None
|
| 98 |
+
batch_size: int
|
| 99 |
+
ct2_threads: int
|
| 100 |
+
tokenize_workers: int
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def summary(self) -> str:
|
| 104 |
+
cpu_part = f"CPU {self.cpu_logical} luồng"
|
| 105 |
+
if self.has_cuda and self.gpu_name:
|
| 106 |
+
vram = f"{self.vram_gb:.1f} GB" if self.vram_gb else "?"
|
| 107 |
+
device_part = f"GPU {self.gpu_name} ({vram})"
|
| 108 |
+
else:
|
| 109 |
+
device_part = "GPU không có — chạy CPU"
|
| 110 |
+
return (
|
| 111 |
+
f"{cpu_part} · {device_part} · "
|
| 112 |
+
f"batch={self.batch_size} · threads={self.ct2_threads} · "
|
| 113 |
+
f"tokenize_workers={self.tokenize_workers}"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _env_int(name: str) -> int | None:
|
| 118 |
+
raw = os.environ.get(name, "").strip()
|
| 119 |
+
if not raw:
|
| 120 |
+
return None
|
| 121 |
+
try:
|
| 122 |
+
return max(1, int(raw))
|
| 123 |
+
except ValueError:
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _clamp_batch(value: int) -> int:
|
| 128 |
+
return max(BATCH_MIN, min(BATCH_MAX, int(value)))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _clamp_threads(value: int) -> int:
|
| 132 |
+
return max(THREAD_MIN, min(THREAD_MAX, int(value)))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _clamp_tokenize_workers(value: int) -> int:
|
| 136 |
+
return max(TOKENIZE_WORKERS_MIN, min(TOKENIZE_WORKERS_MAX, int(value)))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _round_batch(value: int) -> int:
|
| 140 |
+
"""Làm tròn batch về bội số 4 để ổn định hơn trên GPU."""
|
| 141 |
+
rounded = max(BATCH_MIN, round(value / 4) * 4)
|
| 142 |
+
return _clamp_batch(rounded)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def recommend_tokenize_workers(cpu_logical: int) -> int:
|
| 146 |
+
return max(4, min(cpu_logical, TOKENIZE_WORKERS_MAX))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def recommend_batch_size(cpu_logical: int, *, has_cuda: bool, vram_gb: float | None) -> int:
|
| 150 |
+
if has_cuda:
|
| 151 |
+
# GPU: model ~60M INT8 — VRAM 12GB c�� thể batch lớn; CPU mạnh tokenize song song.
|
| 152 |
+
if vram_gb:
|
| 153 |
+
vram_batch = int(vram_gb * 8)
|
| 154 |
+
else:
|
| 155 |
+
# Khi không cài torch CUDA, CT2 vẫn detect được CUDA nhưng không biết VRAM.
|
| 156 |
+
# Chọn mức vừa phải để chạy ổn trên nhiều máy, người dùng mạnh có thể override.
|
| 157 |
+
vram_batch = 64
|
| 158 |
+
cpu_batch = max(16, cpu_logical * 3)
|
| 159 |
+
return _round_batch(min(vram_batch, cpu_batch, BATCH_MAX))
|
| 160 |
+
|
| 161 |
+
# CPU-only: scale tuyến tính theo số luồng.
|
| 162 |
+
return _round_batch(max(4, cpu_logical))
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def recommend_ct2_threads(cpu_logical: int, *, has_cuda: bool) -> int:
|
| 166 |
+
if has_cuda:
|
| 167 |
+
# GPU inference: tăng thread CT2 để CPU xử lý song song hơn.
|
| 168 |
+
return _clamp_threads(min(cpu_logical, 12))
|
| 169 |
+
|
| 170 |
+
# CPU inference: dùng nhiều luồng hơn.
|
| 171 |
+
return _clamp_threads(cpu_logical)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@lru_cache(maxsize=1)
|
| 175 |
+
def _optional_torch():
|
| 176 |
+
try:
|
| 177 |
+
import torch
|
| 178 |
+
except Exception:
|
| 179 |
+
return None
|
| 180 |
+
return torch
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _ct2_has_cuda() -> bool:
|
| 184 |
+
try:
|
| 185 |
+
return ctranslate2.get_cuda_device_count() > 0
|
| 186 |
+
except Exception:
|
| 187 |
+
return False
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def detect_hardware_profile() -> HardwareProfile:
|
| 191 |
+
cpu_logical = os.cpu_count() or 4
|
| 192 |
+
has_cuda = _ct2_has_cuda()
|
| 193 |
+
gpu_name: str | None = None
|
| 194 |
+
vram_gb: float | None = None
|
| 195 |
+
|
| 196 |
+
if has_cuda:
|
| 197 |
+
torch = _optional_torch()
|
| 198 |
+
if torch is not None:
|
| 199 |
+
try:
|
| 200 |
+
if torch.cuda.is_available():
|
| 201 |
+
props = torch.cuda.get_device_properties(0)
|
| 202 |
+
gpu_name = props.name
|
| 203 |
+
vram_gb = props.total_memory / (1024**3)
|
| 204 |
+
except Exception:
|
| 205 |
+
pass
|
| 206 |
+
if gpu_name is None:
|
| 207 |
+
gpu_name = "CUDA GPU"
|
| 208 |
+
|
| 209 |
+
env_batch = _env_int("HACHIMIMT_BATCH_SIZE")
|
| 210 |
+
env_threads = _env_int("HACHIMIMT_THREADS")
|
| 211 |
+
env_tokenize_workers = _env_int("HACHIMIMT_TOKENIZE_WORKERS")
|
| 212 |
+
|
| 213 |
+
batch_size = (
|
| 214 |
+
_clamp_batch(env_batch)
|
| 215 |
+
if env_batch is not None
|
| 216 |
+
else recommend_batch_size(cpu_logical, has_cuda=has_cuda, vram_gb=vram_gb)
|
| 217 |
+
)
|
| 218 |
+
ct2_threads = (
|
| 219 |
+
_clamp_threads(env_threads)
|
| 220 |
+
if env_threads is not None
|
| 221 |
+
else recommend_ct2_threads(cpu_logical, has_cuda=has_cuda)
|
| 222 |
+
)
|
| 223 |
+
tokenize_workers = (
|
| 224 |
+
_clamp_tokenize_workers(env_tokenize_workers)
|
| 225 |
+
if env_tokenize_workers is not None
|
| 226 |
+
else recommend_tokenize_workers(cpu_logical)
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
return HardwareProfile(
|
| 230 |
+
cpu_logical=cpu_logical,
|
| 231 |
+
has_cuda=has_cuda,
|
| 232 |
+
gpu_name=gpu_name,
|
| 233 |
+
vram_gb=vram_gb,
|
| 234 |
+
batch_size=batch_size,
|
| 235 |
+
ct2_threads=ct2_threads,
|
| 236 |
+
tokenize_workers=tokenize_workers,
|
| 237 |
+
)
|
src/honorific_normalize.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chuẩn hóa xưng hô Hán-Việt cho bản dịch (hậu kỳ, neo nguồn ZH).
|
| 2 |
+
|
| 3 |
+
Người đọc muốn xưng hô nhất quán Hán-Việt (tỷ/muội/ca ca/sư huynh/hắn/nàng/ta),
|
| 4 |
+
không nhảy "chị em" ↔ "tỷ muội" trong một chương.
|
| 5 |
+
|
| 6 |
+
Nguyên lý: CHỈ đổi khi NGUỒN tiếng Trung có từ xưng hô tường minh (neo nguồn ZH).
|
| 7 |
+
Chỗ nguồn không có từ → không đụng. KHÔNG suy luận quan hệ. Thuần hậu kỳ, không
|
| 8 |
+
cần model. Áp per-row (mỗi câu/đoạn có cặp zh-vi riêng → neo chính xác).
|
| 9 |
+
|
| 10 |
+
API (khớp phong cách text_preprocess.py):
|
| 11 |
+
normalize_honorifics(zh, vi, mode="off", kinship_mode="always") -> str
|
| 12 |
+
honorific_message(mode) -> str
|
| 13 |
+
HONORIFIC_MODES = {off, safe, xianxia_strict}
|
| 14 |
+
|
| 15 |
+
Port từ MT-zhvi-medium-train/scripts/pronoun/honorific_normalizer.py (qua TDD,
|
| 16 |
+
68 test). Đại từ chỉ bật ở mode xianxia_strict + gate cổ trang (tránh phá văn
|
| 17 |
+
hiện đại); thân tộc đơn (哥/姐 1 ký tự) TẮT vì hay trùng tên riêng/loanword.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
import unicodedata
|
| 23 |
+
from collections import Counter
|
| 24 |
+
|
| 25 |
+
HONORIFIC_OFF = "off"
|
| 26 |
+
HONORIFIC_SAFE = "safe"
|
| 27 |
+
HONORIFIC_STRICT = "xianxia_strict"
|
| 28 |
+
HONORIFIC_MODES = {HONORIFIC_OFF, HONORIFIC_SAFE, HONORIFIC_STRICT}
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Bảng tra: ZH-term → {hv (đích Hán-Việt), drift (biến thể VI cần thay), tier}
|
| 32 |
+
# tier "kinship" = thân tộc/cặp/đồng môn/title (áp ở safe)
|
| 33 |
+
# tier "kinship_single" = thân tộc 1 ký tự (TẮT — hay trùng proper-noun)
|
| 34 |
+
# tier "pronoun" = đại từ (chỉ xianxia_strict + gate cổ trang)
|
| 35 |
+
# hv=None = cụm KHÔNG phải xưng hô (chiếm span longest-match, chặn term ngắn lọt).
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
_RAW = {
|
| 38 |
+
# cụm không phải xưng hô (blacklist)
|
| 39 |
+
"哥们": {"hv": None}, "哥们儿": {"hv": None}, "哥儿们": {"hv": None},
|
| 40 |
+
"大哥大": {"hv": None}, "弟媳": {"hv": None}, "弟妹": {"hv": None},
|
| 41 |
+
"小姐": {"hv": None}, "大嫂": {"hv": None}, "嫂子": {"hv": None}, "嫂": {"hv": None},
|
| 42 |
+
# ngoài scope đại từ số nhiều / compound sở hữu: chặn term số ít lọt vào
|
| 43 |
+
"我们": {"hv": None}, "咱们": {"hv": None}, "你们": {"hv": None},
|
| 44 |
+
"他们": {"hv": None}, "她们": {"hv": None},
|
| 45 |
+
"我校": {"hv": None}, "我司": {"hv": None}, "我国": {"hv": None},
|
| 46 |
+
"我方": {"hv": None}, "我院": {"hv": None},
|
| 47 |
+
# đồng môn tu tiên
|
| 48 |
+
"师兄": {"hv": "sư huynh", "drift": ["sư ca", "sư anh"], "tier": "kinship"},
|
| 49 |
+
"师弟": {"hv": "sư đệ", "drift": ["sư em"], "tier": "kinship"},
|
| 50 |
+
"师姐": {"hv": "sư tỷ", "drift": ["sư chị"], "tier": "kinship"},
|
| 51 |
+
"师妹": {"hv": "sư muội", "drift": ["sư em"], "tier": "kinship"},
|
| 52 |
+
"师叔": {"hv": "sư thúc", "drift": [], "tier": "kinship"},
|
| 53 |
+
"师伯": {"hv": "sư bá", "drift": [], "tier": "kinship"},
|
| 54 |
+
"师祖": {"hv": "sư tổ", "drift": [], "tier": "kinship"},
|
| 55 |
+
"徒儿": {"hv": "đồ nhi", "drift": [], "tier": "kinship"},
|
| 56 |
+
# cặp collective
|
| 57 |
+
"姐妹": {"hv": "tỷ muội", "drift": ["chị em"], "tier": "kinship"},
|
| 58 |
+
"兄弟": {"hv": "huynh đệ", "drift": ["anh em"], "tier": "kinship"},
|
| 59 |
+
"兄妹": {"hv": "huynh muội", "drift": ["anh em"], "tier": "kinship"},
|
| 60 |
+
"姐弟": {"hv": "tỷ đệ", "drift": ["chị em"], "tier": "kinship"},
|
| 61 |
+
"师徒": {"hv": "sư đồ", "drift": [], "tier": "kinship"},
|
| 62 |
+
# thân tộc đôi
|
| 63 |
+
"姐姐": {"hv": "tỷ tỷ", "drift": ["chị gái", "chị"], "tier": "kinship"},
|
| 64 |
+
"妹妹": {"hv": "muội muội", "drift": ["em gái"], "tier": "kinship"},
|
| 65 |
+
"哥哥": {"hv": "ca ca", "drift": ["anh trai", "anh"], "tier": "kinship"},
|
| 66 |
+
"弟弟": {"hv": "đệ đệ", "drift": ["em trai"], "tier": "kinship"},
|
| 67 |
+
# titles
|
| 68 |
+
"大哥": {"hv": "đại ca", "drift": ["anh cả", "anh lớn", "anh hai"], "tier": "kinship"},
|
| 69 |
+
"公子": {"hv": "công tử", "drift": [], "tier": "kinship"},
|
| 70 |
+
"姑娘": {"hv": "cô nương", "drift": [], "tier": "kinship"},
|
| 71 |
+
"师尊": {"hv": "sư tôn", "drift": [], "tier": "kinship"},
|
| 72 |
+
"师父": {"hv": "sư phụ", "drift": [], "tier": "kinship"},
|
| 73 |
+
"前辈": {"hv": "tiền bối", "drift": [], "tier": "kinship"},
|
| 74 |
+
"晚辈": {"hv": "vãn bối", "drift": [], "tier": "kinship"},
|
| 75 |
+
"本座": {"hv": "bản tọa", "drift": [], "tier": "kinship"},
|
| 76 |
+
# thân tộc ĐƠN (TẮT mặc định — 1 ký tự hay trùng proper-noun 哥伦比亚/空姐)
|
| 77 |
+
"姐": {"hv": "tỷ", "drift": ["chị"], "tier": "kinship_single"},
|
| 78 |
+
"妹": {"hv": "muội", "drift": [], "tier": "kinship_single"},
|
| 79 |
+
"哥": {"hv": "ca", "drift": ["anh"], "tier": "kinship_single"},
|
| 80 |
+
"弟": {"hv": "đệ", "drift": [], "tier": "kinship_single"},
|
| 81 |
+
# đại từ (pronoun) — drift đã loại từ trùng tên riêng (anh/chị) + "mình"
|
| 82 |
+
"你": {"hv": "ngươi", "drift": ["cậu", "bạn", "m��y"], "tier": "pronoun"},
|
| 83 |
+
"您": {"hv": "ngài", "drift": ["ông", "bác"], "tier": "pronoun"},
|
| 84 |
+
"他": {"hv": "hắn", "drift": ["anh ấy", "anh ta", "cậu ấy", "cậu ta", "gã"], "tier": "pronoun"},
|
| 85 |
+
"她": {"hv": "nàng", "drift": ["cô ấy", "cô ta", "ả"], "tier": "pronoun"},
|
| 86 |
+
"我": {"hv": "ta", "drift": ["tôi", "tớ"], "tier": "pronoun"},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
HONORIFIC_MAP: dict[str, dict] = {}
|
| 90 |
+
for _k, _v in _RAW.items():
|
| 91 |
+
_e = dict(_v)
|
| 92 |
+
if _e.get("drift"):
|
| 93 |
+
_e["drift"] = sorted(_e["drift"], key=len, reverse=True) # phrase dài trước
|
| 94 |
+
HONORIFIC_MAP[_k] = _e
|
| 95 |
+
|
| 96 |
+
_TERMS_BY_LEN = sorted(HONORIFIC_MAP.keys(), key=len, reverse=True)
|
| 97 |
+
_TARGET_LONG_PHRASES = tuple(
|
| 98 |
+
sorted(
|
| 99 |
+
{
|
| 100 |
+
drift
|
| 101 |
+
for entry in HONORIFIC_MAP.values()
|
| 102 |
+
for drift in entry.get("drift", [])
|
| 103 |
+
if " " in drift
|
| 104 |
+
},
|
| 105 |
+
key=len,
|
| 106 |
+
reverse=True,
|
| 107 |
+
)
|
| 108 |
+
)
|
| 109 |
+
_GENERIC_KINSHIP_SINGLE_DRIFTS = {"anh", "chị", "em"}
|
| 110 |
+
|
| 111 |
+
WUXIA_SIGNALS = [
|
| 112 |
+
"修士", "修真", "修仙", "元婴", "金丹", "筑基", "真君", "法宝", "丹药", "灵气",
|
| 113 |
+
"仙人", "仙子", "剑修", "渡劫", "结丹", "化神", "真人", "道君", "宗门", "灵根",
|
| 114 |
+
"本座", "贫道", "道友", "天劫", "神识", "真元", "灵石", "符箓", "阵法", "飞剑",
|
| 115 |
+
]
|
| 116 |
+
MODERN_SIGNALS = [
|
| 117 |
+
"公司", "大学", "电话", "手机", "电脑", "网络", "汽车", "老板", "经理", "项目",
|
| 118 |
+
"咖啡", "地铁", "飞机", "酒店", "警察", "医院", "护士", "短信", "微信", "视频",
|
| 119 |
+
"直播", "电视", "银行", "信用卡", "互联网", "程序", "软件", "总裁", "董事长",
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def genre_score(zh: str) -> tuple[int, int]:
|
| 124 |
+
c = sum(1 for s in WUXIA_SIGNALS if s in zh)
|
| 125 |
+
m = sum(1 for s in MODERN_SIGNALS if s in zh)
|
| 126 |
+
return c, m
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def is_classical(zh: str) -> bool:
|
| 130 |
+
"""Có signal cổ trang VÀ không lẫn hiện đại → coi là cổ trang (bảo thủ).
|
| 131 |
+
Nếu có signal hiện đại thì cần cổ trang áp đảo (chặn 'hiện đại tu tiên')."""
|
| 132 |
+
c, m = genre_score(zh)
|
| 133 |
+
if m == 0:
|
| 134 |
+
return c >= 1
|
| 135 |
+
return c >= 2 and c > m
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def longest_match_mentions(zh: str) -> list[str]:
|
| 139 |
+
"""Quét nguồn trái→phải, tại mỗi vị trí chọn term dài nhất.
|
| 140 |
+
|
| 141 |
+
Cách này giữ đúng thứ tự mention trong nguồn và vẫn chặn term ngắn lọt vào
|
| 142 |
+
span term dài (哥哥 chặn 哥; 我们 chặn 我).
|
| 143 |
+
"""
|
| 144 |
+
mentions: list[str] = []
|
| 145 |
+
i = 0
|
| 146 |
+
while i < len(zh):
|
| 147 |
+
matched = ""
|
| 148 |
+
for term in _TERMS_BY_LEN:
|
| 149 |
+
if zh.startswith(term, i):
|
| 150 |
+
matched = term
|
| 151 |
+
break
|
| 152 |
+
if matched:
|
| 153 |
+
mentions.append(matched)
|
| 154 |
+
i += len(matched)
|
| 155 |
+
else:
|
| 156 |
+
i += 1
|
| 157 |
+
return mentions
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def longest_match_terms(zh: str) -> dict[str, int]:
|
| 161 |
+
return dict(Counter(longest_match_mentions(zh)))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _match_case(new: str, old: str) -> str:
|
| 165 |
+
return new[:1].upper() + new[1:] if old[:1].isupper() else new
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _target_spans(text: str, phrase: str) -> list[tuple[int, int]]:
|
| 169 |
+
pat = r"(?<![A-Za-zÀ-ỹ])" + re.escape(phrase) + r"(?![A-Za-zÀ-ỹ])"
|
| 170 |
+
return [(m.start(), m.end()) for m in re.finditer(pat, text, flags=re.IGNORECASE)]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _inside_any_span(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
|
| 174 |
+
return any(span_start <= start and end <= span_end for span_start, span_end in spans)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _protected_target_spans(vi: str, current_variant: str) -> list[tuple[int, int]]:
|
| 178 |
+
"""Các phrase dài không được để drift một từ ăn vào giữa."""
|
| 179 |
+
spans: list[tuple[int, int]] = []
|
| 180 |
+
for phrase in _TARGET_LONG_PHRASES:
|
| 181 |
+
if phrase == current_variant:
|
| 182 |
+
continue
|
| 183 |
+
if f" {current_variant.casefold()}" not in f" {phrase.casefold()}":
|
| 184 |
+
continue
|
| 185 |
+
spans.extend(_target_spans(vi, phrase))
|
| 186 |
+
return spans
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _has_pronoun_competition(variant: str, source_mentions: list[str]) -> bool:
|
| 190 |
+
variant = variant.casefold()
|
| 191 |
+
if variant == "anh":
|
| 192 |
+
return "他" in source_mentions
|
| 193 |
+
if variant == "em":
|
| 194 |
+
return "你" in source_mentions
|
| 195 |
+
return False
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _replace_one_drift(
|
| 199 |
+
vi: str,
|
| 200 |
+
variants: list[str],
|
| 201 |
+
target: str,
|
| 202 |
+
*,
|
| 203 |
+
tier: str,
|
| 204 |
+
source_mentions: list[str],
|
| 205 |
+
) -> str:
|
| 206 |
+
"""Thay một mention theo thứ tự nguồn.
|
| 207 |
+
|
| 208 |
+
Nếu chỉ còn drift một từ mơ hồ (`anh/chị/em`) trong dòng có đại từ nguồn
|
| 209 |
+
cạnh tranh, bỏ qua để tránh kiểu `Anh ấy` → `Ca ca ấy`.
|
| 210 |
+
"""
|
| 211 |
+
done = 0
|
| 212 |
+
for v in variants:
|
| 213 |
+
if tier.startswith("kinship") and v.casefold() in _GENERIC_KINSHIP_SINGLE_DRIFTS:
|
| 214 |
+
if _has_pronoun_competition(v, source_mentions):
|
| 215 |
+
continue
|
| 216 |
+
pat = r"(?<![A-Za-zÀ-ỹ])(" + re.escape(v) + r")(?![A-Za-zÀ-ỹ])"
|
| 217 |
+
protected_spans = _protected_target_spans(vi, v)
|
| 218 |
+
|
| 219 |
+
def repl(m):
|
| 220 |
+
nonlocal done
|
| 221 |
+
if done >= 1 or _inside_any_span(m.start(), m.end(), protected_spans):
|
| 222 |
+
return m.group(0)
|
| 223 |
+
done += 1
|
| 224 |
+
return _match_case(target, m.group(0))
|
| 225 |
+
|
| 226 |
+
vi = re.sub(pat, repl, vi, flags=re.IGNORECASE)
|
| 227 |
+
if done:
|
| 228 |
+
break
|
| 229 |
+
return vi
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _skip(tier: str, classical: bool, apply_kinship: bool, apply_pronouns: bool,
|
| 233 |
+
kinship_mode: str, enable_single: bool) -> bool:
|
| 234 |
+
"""True = bỏ qua term. 2 cờ độc lập: apply_kinship (thân tộc/cặp/title) +
|
| 235 |
+
apply_pronouns (đại từ). Đại từ luôn còn gate cổ trang."""
|
| 236 |
+
if tier == "pronoun":
|
| 237 |
+
return (not apply_pronouns) or (not classical)
|
| 238 |
+
if tier == "kinship_single":
|
| 239 |
+
if not apply_kinship or not enable_single:
|
| 240 |
+
return True
|
| 241 |
+
return kinship_mode == "classical_only" and not classical
|
| 242 |
+
if tier == "kinship":
|
| 243 |
+
if not apply_kinship:
|
| 244 |
+
return True
|
| 245 |
+
return kinship_mode == "classical_only" and not classical
|
| 246 |
+
return False
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def honorific_mode(mode: str | None) -> str:
|
| 250 |
+
mode = (mode or HONORIFIC_OFF).strip().lower()
|
| 251 |
+
return mode if mode in HONORIFIC_MODES else HONORIFIC_OFF
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _mode_to_flags(mode: str) -> tuple[bool, bool]:
|
| 255 |
+
"""map mode lũy tiến (tương thích ngược) → (apply_kinship, apply_pronouns)."""
|
| 256 |
+
mode = honorific_mode(mode)
|
| 257 |
+
if mode == HONORIFIC_SAFE:
|
| 258 |
+
return True, False
|
| 259 |
+
if mode == HONORIFIC_STRICT:
|
| 260 |
+
return True, True
|
| 261 |
+
return False, False # off
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def normalize_honorifics(zh: str, vi: str, mode: str | None = None,
|
| 265 |
+
*, apply_kinship: bool | None = None,
|
| 266 |
+
apply_pronouns: bool | None = None,
|
| 267 |
+
kinship_mode: str = "always",
|
| 268 |
+
enable_single: bool = False,
|
| 269 |
+
classical_context: bool | None = None) -> str:
|
| 270 |
+
"""Chuẩn hóa xưng hô VI theo neo nguồn ZH.
|
| 271 |
+
|
| 272 |
+
2 cờ ĐỘC LẬP (ưu tiên nếu truyền): apply_kinship (thân tộc tỷ/muội/ca ca...) +
|
| 273 |
+
apply_pronouns (đại từ ngươi/hắn/nàng/ta, vẫn gate cổ trang). Cho phép mọi tổ hợp,
|
| 274 |
+
gồm 'chỉ đại từ'. `mode` (off/safe/xianxia_strict) = tương thích ngược, map sang
|
| 275 |
+
2 cờ khi 2 cờ không được truyền."""
|
| 276 |
+
if apply_kinship is None and apply_pronouns is None:
|
| 277 |
+
apply_kinship, apply_pronouns = _mode_to_flags(mode)
|
| 278 |
+
else:
|
| 279 |
+
apply_kinship = bool(apply_kinship)
|
| 280 |
+
apply_pronouns = bool(apply_pronouns)
|
| 281 |
+
|
| 282 |
+
if (not apply_kinship and not apply_pronouns) or not vi or not zh:
|
| 283 |
+
return vi
|
| 284 |
+
vi = unicodedata.normalize("NFC", vi)
|
| 285 |
+
zh = unicodedata.normalize("NFC", zh)
|
| 286 |
+
mentions = longest_match_mentions(zh)
|
| 287 |
+
if not mentions:
|
| 288 |
+
return vi
|
| 289 |
+
classical = bool(classical_context) if classical_context is not None else is_classical(zh)
|
| 290 |
+
for term in mentions:
|
| 291 |
+
entry = HONORIFIC_MAP[term]
|
| 292 |
+
hv = entry.get("hv")
|
| 293 |
+
if hv is None:
|
| 294 |
+
continue
|
| 295 |
+
if _skip(entry["tier"], classical, apply_kinship, apply_pronouns,
|
| 296 |
+
kinship_mode, enable_single):
|
| 297 |
+
continue
|
| 298 |
+
drift = entry.get("drift", [])
|
| 299 |
+
if not drift:
|
| 300 |
+
continue
|
| 301 |
+
vi = _replace_one_drift(
|
| 302 |
+
vi,
|
| 303 |
+
drift,
|
| 304 |
+
hv,
|
| 305 |
+
tier=entry["tier"],
|
| 306 |
+
source_mentions=mentions,
|
| 307 |
+
)
|
| 308 |
+
return vi
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# alias để test suite port (dùng tên `normalize`) chạy được
|
| 312 |
+
normalize = normalize_honorifics
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def honorific_message(mode: str | None) -> str:
|
| 316 |
+
mode = honorific_mode(mode)
|
| 317 |
+
if mode == HONORIFIC_OFF:
|
| 318 |
+
return "Giữ nguyên xưng hô theo bản dịch."
|
| 319 |
+
if mode == HONORIFIC_SAFE:
|
| 320 |
+
return "Đã chuẩn hóa xưng hô thân tộc sang Hán-Việt (tỷ/muội/ca ca...)."
|
| 321 |
+
return "Đã chuẩn hóa xưng hô Hán-Việt gồm cả đại từ (ngươi/hắn/nàng/ta)."
|
src/postprocess_policy.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Iterable
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
GENRE_MODERN = "modern"
|
| 8 |
+
GENRE_CLASSICAL = "classical"
|
| 9 |
+
GENRE_MIXED_GUARD = "mixed_guard"
|
| 10 |
+
GENRE_UNKNOWN_GUARD = "unknown_guard"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class GenreDecision:
|
| 15 |
+
route: str
|
| 16 |
+
classical_score: int
|
| 17 |
+
modern_score: int
|
| 18 |
+
evidence: tuple[str, ...]
|
| 19 |
+
reason: str
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def is_modern(self) -> bool:
|
| 23 |
+
return self.route == GENRE_MODERN
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def is_classical(self) -> bool:
|
| 27 |
+
return self.route == GENRE_CLASSICAL
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
BOOK_MODERN_HINTS = ("没钱修什么仙",)
|
| 31 |
+
|
| 32 |
+
HARD_CLASSICAL_SIGNALS = (
|
| 33 |
+
"修士", "修真", "修仙", "元婴", "金丹", "筑基", "真君", "法宝", "丹药", "灵气",
|
| 34 |
+
"仙人", "仙子", "剑修", "渡劫", "结丹", "化神", "真人", "道君", "宗门", "灵根",
|
| 35 |
+
"本座", "贫道", "道友", "天劫", "神识", "真元", "灵石", "符箓", "阵法", "飞剑",
|
| 36 |
+
"皇帝", "王爷", "陛下", "皇后", "太后", "公主", "太子", "侯爷", "世子",
|
| 37 |
+
"江湖", "武林", "内力", "剑客", "掌门", "少侠", "师尊", "师父", "前辈",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
HARD_MODERN_SIGNALS = (
|
| 41 |
+
"公司", "大学", "高中", "初中", "小学", "学校", "老师", "班主任", "同学", "学生",
|
| 42 |
+
"课堂", "上课", "课程", "高一", "高二", "高三", "面试", "招生", "学费", "补习班",
|
| 43 |
+
"电话", "手机", "电脑", "网络", "汽车", "地铁", "飞机", "酒店", "警察", "医院",
|
| 44 |
+
"护士", "短信", "微信", "视频", "直播", "电视", "银行", "信用卡", "互联网",
|
| 45 |
+
"程序", "软件", "老板", "经理", "项目", "咖啡", "贷款", "借款", "逾期",
|
| 46 |
+
"财务公司", "平台", "总裁", "董事长", "办公室",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _joined_sources(rows_or_text: Iterable[tuple[int, str, str]] | str) -> str:
|
| 51 |
+
if isinstance(rows_or_text, str):
|
| 52 |
+
return rows_or_text
|
| 53 |
+
return "\n".join(str(source) for _, source, _ in rows_or_text)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _hits(text: str, terms: tuple[str, ...]) -> tuple[str, ...]:
|
| 57 |
+
return tuple(term for term in terms if term in text)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def classify_genre(rows_or_text: Iterable[tuple[int, str, str]] | str) -> GenreDecision:
|
| 61 |
+
text = _joined_sources(rows_or_text)
|
| 62 |
+
book_hits = _hits(text, BOOK_MODERN_HINTS)
|
| 63 |
+
if book_hits:
|
| 64 |
+
return GenreDecision(
|
| 65 |
+
route=GENRE_MODERN,
|
| 66 |
+
classical_score=0,
|
| 67 |
+
modern_score=len(book_hits),
|
| 68 |
+
evidence=book_hits,
|
| 69 |
+
reason="book_modern_hint",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
classical_hits = _hits(text, HARD_CLASSICAL_SIGNALS)
|
| 73 |
+
modern_hits = _hits(text, HARD_MODERN_SIGNALS)
|
| 74 |
+
classical_score = len(classical_hits)
|
| 75 |
+
modern_score = len(modern_hits)
|
| 76 |
+
evidence = (*classical_hits[:8], *modern_hits[:8])
|
| 77 |
+
|
| 78 |
+
if classical_score and not modern_score:
|
| 79 |
+
return GenreDecision(GENRE_CLASSICAL, classical_score, modern_score, evidence, "classical_signal")
|
| 80 |
+
if modern_score and not classical_score:
|
| 81 |
+
return GenreDecision(GENRE_MODERN, classical_score, modern_score, evidence, "modern_signal")
|
| 82 |
+
if classical_score and modern_score:
|
| 83 |
+
return GenreDecision(GENRE_MIXED_GUARD, classical_score, modern_score, evidence, "mixed_signal")
|
| 84 |
+
return GenreDecision(GENRE_UNKNOWN_GUARD, classical_score, modern_score, evidence, "no_signal")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def v9_route_for_decision(decision: GenreDecision) -> str:
|
| 88 |
+
if decision.route == GENRE_MODERN:
|
| 89 |
+
return "modern_school"
|
| 90 |
+
if decision.route == GENRE_UNKNOWN_GUARD:
|
| 91 |
+
return "unknown_copy_guard"
|
| 92 |
+
return "xianxia_copy_guard"
|
| 93 |
+
|
src/progress_tracker.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared progress state — polled by UI timer during long translation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class ProgressState:
|
| 11 |
+
pct: float = 0.0
|
| 12 |
+
message: str = "Sẵn sàng."
|
| 13 |
+
running: bool = False
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
_lock = threading.Lock()
|
| 17 |
+
_state = ProgressState()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def set_progress(pct: float, message: str, *, running: bool = True) -> None:
|
| 21 |
+
with _lock:
|
| 22 |
+
_state.pct = max(0.0, min(100.0, float(pct)))
|
| 23 |
+
_state.message = message
|
| 24 |
+
_state.running = running
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def finish_progress(message: str) -> None:
|
| 28 |
+
with _lock:
|
| 29 |
+
_state.pct = 100.0
|
| 30 |
+
_state.message = message
|
| 31 |
+
_state.running = False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def reset_progress(message: str = "Sẵn sàng.") -> None:
|
| 35 |
+
with _lock:
|
| 36 |
+
_state.pct = 0.0
|
| 37 |
+
_state.message = message
|
| 38 |
+
_state.running = False
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def snapshot() -> ProgressState:
|
| 42 |
+
with _lock:
|
| 43 |
+
return ProgressState(
|
| 44 |
+
pct=_state.pct,
|
| 45 |
+
message=_state.message,
|
| 46 |
+
running=_state.running,
|
| 47 |
+
)
|
src/pronoun_harmonizer_v9.py
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime V9 pronoun stabilizer for HachimiMT UI rows.
|
| 2 |
+
|
| 3 |
+
This is a self-contained port of the promoted vp-mt-train V9 runtime layer.
|
| 4 |
+
It targets modern relation pronoun stability (teacher/student, mother/child,
|
| 5 |
+
peer, inner monologue guards) and leaves xianxia/cổ trang text untouched by
|
| 6 |
+
default through a copy-guard route.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from collections import Counter
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from postprocess_policy import classify_genre, v9_route_for_decision
|
| 16 |
+
|
| 17 |
+
ROUTES = ("auto", "modern_school", "xianxia_copy_guard", "unknown_copy_guard")
|
| 18 |
+
POLICY_MODES = ("stability_first",)
|
| 19 |
+
|
| 20 |
+
WORD_CHARS = r"\wÀ-ỹ"
|
| 21 |
+
ALLOWED_PREVIOUS_TA = {"người", "anh", "chị", "cô", "ông", "bà", "hắn", "cậu", "chúng", "trường"}
|
| 22 |
+
ALLOWED_PREVIOUS_TOI = {"chúng", "tụi", "bọn", "mẹ", "bố", "cha"}
|
| 23 |
+
|
| 24 |
+
SCHOOL_EXCLUSIVE_SOURCE_TERMS = ("本校", "我校", "学校", "校方", "校长", "招生", "入学", "全校")
|
| 25 |
+
SCHOOL_CONTEXT_SOURCE_TERMS = ("老师", "教师", "班主任", "同学", "学生", "课堂", "上课", "课程")
|
| 26 |
+
TEACHER_SOURCE_TERMS = ("老师", "教师", "班主任")
|
| 27 |
+
TEACHER_STUDENT_SOURCE_TERMS = (
|
| 28 |
+
"卷子", "办公室", "高一", "高二", "高三", "月考", "示范班", "学生", "基础",
|
| 29 |
+
)
|
| 30 |
+
MOTHER_SOURCE_TERMS = ("母亲", "妈妈", "老妈", "妈")
|
| 31 |
+
TEACHER_SPEAKER_SOURCE_TERMS = ("王海", "体育老师")
|
| 32 |
+
NON_TEACHER_SPEAKER_SOURCE_TERMS = ("白真真", "周天翊", "少年", "少女")
|
| 33 |
+
INNER_MONOLOGUE_SOURCE_TERMS = ("心中", "心道", "暗道", "心想")
|
| 34 |
+
TEACHER_WINDOW_TAGS = {"teacher_student_window", "student_teacher_window"}
|
| 35 |
+
FORMAL_DEBT_SOURCE_TERMS = ("借款", "逾期", "平台", "贷款", "财务公司", "家属")
|
| 36 |
+
INSTITUTION_WE_SOURCE_TERMS = (
|
| 37 |
+
"我们的学校", "我们学校", "我校", "我们为", "我们推出", "我们了解到", "我们平台",
|
| 38 |
+
"我们公司", "我们知道", "我们这", "我们补习班", "我们在饮用水",
|
| 39 |
+
"我们指定",
|
| 40 |
+
)
|
| 41 |
+
INSTITUTION_APPLICANT_SOURCE_TERMS = ("面试官", "报考")
|
| 42 |
+
PEER_CLASSMATE_SOURCE_TERMS = ("白真真", "帮我还了债")
|
| 43 |
+
SCHOOL_APPLICATION_SOURCE_TERMS = ("我能报考", "我可以报考", "我能报名", "我可以报名")
|
| 44 |
+
CHILD_TO_MOTHER_SOURCE_TERMS = ("妈,", "妈,", "妈妈,", "妈妈,")
|
| 45 |
+
DIRECT_MOTHER_SPEAKER_MARKERS = ("母亲:", "母亲:", "妈妈:", "妈妈:", "妈:", "妈:")
|
| 46 |
+
MOTHER_MESSAGE_CONTEXT_TERMS = ("母亲发来的消息", "母亲发来")
|
| 47 |
+
APPLICANT_CONTEXT_SOURCE_TERMS = (
|
| 48 |
+
"面试", "面试官", "考生", "报考", "入学", "招生", "学费", "学校", "高中",
|
| 49 |
+
"录取率", "入学标准", "推荐生", "贫困生", "特长生", "补习班",
|
| 50 |
+
)
|
| 51 |
+
APPLICANT_SELF_SOURCE_TERMS = ("张羽", "考生", "同学")
|
| 52 |
+
GENERIC_MODERN_DIALOGUE_GUARD_TERMS = (
|
| 53 |
+
"老者", "少年", "师尊", "前辈", "天庭", "传功", "功力", "法力贷", "本尊",
|
| 54 |
+
)
|
| 55 |
+
OLDER_BROTHER_SOURCE_TERMS = ("哥哥", "你哥", "哥")
|
| 56 |
+
YOUNGER_SISTER_SOURCE_TERMS = ("妹妹", "妹")
|
| 57 |
+
SIBLING_CONTEXT_SOURCE_TERMS = (
|
| 58 |
+
"哥哥", "妹妹", "弟弟", "姐姐", "兄妹", "姐妹", "姐弟", "兄弟",
|
| 59 |
+
"你哥", "你哥哥", "你姐", "你姐姐", "你弟", "你妹妹",
|
| 60 |
+
)
|
| 61 |
+
STABILITY_GENERIC_FLAG_PREFIXES = (
|
| 62 |
+
"forbidden:modern_you:",
|
| 63 |
+
"forbidden:modern_self:",
|
| 64 |
+
"watch:inclusive_we:",
|
| 65 |
+
)
|
| 66 |
+
ACTIONABLE_POLICY_TAGS = {
|
| 67 |
+
"teacher_student",
|
| 68 |
+
"student_teacher",
|
| 69 |
+
"family_mother_child",
|
| 70 |
+
"formal_debt_call",
|
| 71 |
+
"family_brother_to_sister",
|
| 72 |
+
"family_sister_to_brother_context",
|
| 73 |
+
"family_child_to_mother",
|
| 74 |
+
"school_application_self",
|
| 75 |
+
"institution_we_exclusive",
|
| 76 |
+
"institution_applicant",
|
| 77 |
+
"applicant_self",
|
| 78 |
+
"generic_modern_dialogue",
|
| 79 |
+
"sibling_older_brother_to_younger",
|
| 80 |
+
"sibling_younger_to_older_brother",
|
| 81 |
+
}
|
| 82 |
+
SUPERNATURAL_GUARD_TERMS = (
|
| 83 |
+
"布娃娃", "苍老", "神识", "戒指", "因果", "请神", "仪式", "愿望", "反噬", "魂飞魄散",
|
| 84 |
+
)
|
| 85 |
+
SUPERNATURAL_CONTEXT_TERMS = (
|
| 86 |
+
"布娃娃", "邪神", "苍老", "神识", "戒指", "因果", "请神", "仪式", "愿望", "反噬", "魂飞魄散",
|
| 87 |
+
)
|
| 88 |
+
WUXIA_ROUTE_TERMS = (
|
| 89 |
+
"修士", "修真", "修仙", "元婴", "金丹", "筑基", "真君", "法宝", "丹药", "灵气",
|
| 90 |
+
"仙人", "仙子", "剑修", "渡劫", "结丹", "化神", "真人", "道君", "宗门", "灵根",
|
| 91 |
+
"本座", "贫道", "道友", "天劫", "神识", "真元", "灵石", "符箓", "阵法", "飞剑",
|
| 92 |
+
)
|
| 93 |
+
MODERN_ROUTE_TERMS = (
|
| 94 |
+
"学校", "老师", "班主任", "同学", "学生", "高一", "高二", "高三", "公司", "电话",
|
| 95 |
+
"手机", "电脑", "网络", "老板", "经理", "项目", "咖啡", "银行", "贷款", "财务公司",
|
| 96 |
+
"母亲", "妈妈", "老妈", "妈", "哥哥", "妹妹", "姐姐", "弟弟", "总裁", "董事长", "办公室",
|
| 97 |
+
)
|
| 98 |
+
MODERN_BOOK_HINT_TERMS = ("没钱修什么仙",)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def cap_like(source: str, replacement: str) -> str:
|
| 102 |
+
return replacement[:1].upper() + replacement[1:] if source and source[0].isupper() else replacement
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def replace_word(text: str, source: str, replacement: str) -> tuple[str, int]:
|
| 106 |
+
pattern = re.compile(rf"(?<![{WORD_CHARS}]){re.escape(source)}(?![{WORD_CHARS}])", re.IGNORECASE)
|
| 107 |
+
count = 0
|
| 108 |
+
|
| 109 |
+
def repl(match: re.Match[str]) -> str:
|
| 110 |
+
nonlocal count
|
| 111 |
+
count += 1
|
| 112 |
+
return cap_like(match.group(0), replacement)
|
| 113 |
+
|
| 114 |
+
return pattern.sub(repl, text), count
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def apply_replacement(text: str, source: str, replacement: str, applied: Counter[str], rule_name: str) -> str:
|
| 118 |
+
new_text, count = replace_word(text, source, replacement)
|
| 119 |
+
if count:
|
| 120 |
+
applied[rule_name] += count
|
| 121 |
+
return new_text
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def contains_source_any(source: str, terms: tuple[str, ...]) -> bool:
|
| 125 |
+
return any(term in source for term in terms)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def source_has_all(source: str, terms: tuple[str, ...]) -> bool:
|
| 129 |
+
return all(term in source for term in terms)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def has_speech_marker(source: str) -> bool:
|
| 133 |
+
return "“" in source or "”" in source or '"' in source or ":" in source or ":" in source
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def dialogue_prefix(source: str) -> str:
|
| 137 |
+
positions = [source.find(marker) for marker in ("“", '"', ":", ":") if source.find(marker) >= 0]
|
| 138 |
+
return source[: min(positions)] if positions else source
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def dialogue_suffix(source: str) -> str:
|
| 142 |
+
positions = [source.rfind(marker) for marker in ("”", '"') if source.rfind(marker) >= 0]
|
| 143 |
+
return source[max(positions) + 1 :] if positions else ""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def prefix_has_non_teacher_speaker(prefix: str) -> bool:
|
| 147 |
+
return contains_source_any(prefix, NON_TEACHER_SPEAKER_SOURCE_TERMS)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def is_clear_non_teacher_speaker_source(source: str) -> bool:
|
| 151 |
+
if not has_speech_marker(source):
|
| 152 |
+
return False
|
| 153 |
+
prefix = dialogue_prefix(source)
|
| 154 |
+
if not prefix_has_non_teacher_speaker(prefix):
|
| 155 |
+
return False
|
| 156 |
+
return contains_source_any(prefix, ("道", "说", "问", "开口", "哀叹", "凑到", "嘀咕"))
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def is_inner_monologue_source(source: str) -> bool:
|
| 160 |
+
return contains_source_any(dialogue_prefix(source), INNER_MONOLOGUE_SOURCE_TERMS)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def is_teacher_speaker_source(source: str) -> bool:
|
| 164 |
+
if "你" not in source:
|
| 165 |
+
return False
|
| 166 |
+
prefix = dialogue_prefix(source)
|
| 167 |
+
if prefix_has_non_teacher_speaker(prefix):
|
| 168 |
+
return False
|
| 169 |
+
return contains_source_any(prefix, (*TEACHER_SOURCE_TERMS, *TEACHER_SPEAKER_SOURCE_TERMS))
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def standalone_pronoun_matches(text: str, term: str, excluded_previous: set[str]) -> list[re.Match[str]]:
|
| 173 |
+
matches: list[re.Match[str]] = []
|
| 174 |
+
pattern = re.compile(rf"(?<![{WORD_CHARS}]){re.escape(term)}(?![{WORD_CHARS}])", re.IGNORECASE)
|
| 175 |
+
for match in pattern.finditer(text):
|
| 176 |
+
before = text[: match.start()].rstrip()
|
| 177 |
+
previous = before.rsplit(" ", 1)[-1].casefold() if before else ""
|
| 178 |
+
if previous in excluded_previous:
|
| 179 |
+
continue
|
| 180 |
+
matches.append(match)
|
| 181 |
+
return matches
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def standalone_ta_matches(text: str) -> list[re.Match[str]]:
|
| 185 |
+
return standalone_pronoun_matches(text, "ta", ALLOWED_PREVIOUS_TA)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def contains_word(text: str, term: str) -> bool:
|
| 189 |
+
if term == "ta":
|
| 190 |
+
return bool(standalone_ta_matches(text))
|
| 191 |
+
pattern = re.compile(rf"(?<![{WORD_CHARS}]){re.escape(term)}(?![{WORD_CHARS}])", re.IGNORECASE)
|
| 192 |
+
return bool(pattern.search(text))
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def school_we_is_exclusive(source: str, text: str) -> bool:
|
| 196 |
+
lowered = text.casefold()
|
| 197 |
+
if "trường ta" in lowered or "trường chúng ta" in lowered:
|
| 198 |
+
return True
|
| 199 |
+
return contains_word(text, "chúng ta") and contains_source_any(source, SCHOOL_EXCLUSIVE_SOURCE_TERMS)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def detect_flags(source: str, text: str) -> list[str]:
|
| 203 |
+
flags: list[str] = []
|
| 204 |
+
if contains_word(text, "ngươi"):
|
| 205 |
+
flags.append("forbidden:modern_you:ngươi")
|
| 206 |
+
if contains_word(text, "ta"):
|
| 207 |
+
flags.append("forbidden:modern_self:ta")
|
| 208 |
+
if school_we_is_exclusive(source, text):
|
| 209 |
+
lowered = text.casefold()
|
| 210 |
+
if "trường ta" in lowered:
|
| 211 |
+
flags.append("forbidden:school_we_exclusive:trường ta")
|
| 212 |
+
if "trường chúng ta" in lowered:
|
| 213 |
+
flags.append("forbidden:school_we_exclusive:trường chúng ta")
|
| 214 |
+
if contains_word(text, "chúng ta"):
|
| 215 |
+
flags.append("forbidden:school_we_exclusive:chúng ta")
|
| 216 |
+
elif contains_word(text, "chúng ta"):
|
| 217 |
+
flags.append("watch:inclusive_we:chúng ta")
|
| 218 |
+
if contains_source_any(source, MOTHER_SOURCE_TERMS):
|
| 219 |
+
for phrase in ("nàng", "cô ấy", "cô ta"):
|
| 220 |
+
if contains_word(text, phrase):
|
| 221 |
+
flags.append(f"forbidden:mother_third_person_modern:{phrase}")
|
| 222 |
+
elif contains_word(text, "nàng"):
|
| 223 |
+
flags.append("forbidden:female_third_person_modern:nàng")
|
| 224 |
+
return flags
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def forbidden_flags(flags: list[str]) -> list[str]:
|
| 228 |
+
return [flag for flag in flags if flag.startswith("forbidden:")]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def actionable_flags(flags: list[str]) -> list[str]:
|
| 232 |
+
return [
|
| 233 |
+
flag
|
| 234 |
+
for flag in flags
|
| 235 |
+
if not any(flag.startswith(prefix) for prefix in STABILITY_GENERIC_FLAG_PREFIXES)
|
| 236 |
+
]
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def has_actionable_policy_tags(policy_tags: list[str]) -> bool:
|
| 240 |
+
return bool(set(policy_tags) & ACTIONABLE_POLICY_TAGS)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def is_teacher_peer_source(source: str) -> bool:
|
| 244 |
+
return "老师" in source and source.count("老师") >= 2
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def is_teacher_to_student_source(source: str) -> bool:
|
| 248 |
+
if is_teacher_peer_source(source):
|
| 249 |
+
return False
|
| 250 |
+
if "你" not in source and "你们" not in source:
|
| 251 |
+
return False
|
| 252 |
+
if contains_source_any(source, TEACHER_SOURCE_TERMS):
|
| 253 |
+
return True
|
| 254 |
+
if "大学同学" in source:
|
| 255 |
+
return False
|
| 256 |
+
return contains_source_any(source, TEACHER_STUDENT_SOURCE_TERMS)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def is_mother_to_child_source(source: str) -> bool:
|
| 260 |
+
if "你" not in source:
|
| 261 |
+
return False
|
| 262 |
+
if "妈," in source or "妈," in source or "您" in source:
|
| 263 |
+
return False
|
| 264 |
+
return contains_source_any(source, ("妈", "妈妈", "母亲", "老妈"))
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def is_formal_debt_source(source: str) -> bool:
|
| 268 |
+
if ("您" in source or "先生" in source) and contains_source_any(source, FORMAL_DEBT_SOURCE_TERMS):
|
| 269 |
+
return True
|
| 270 |
+
return (
|
| 271 |
+
source_has_all(source, ("财务公司", "贷款"))
|
| 272 |
+
or source_has_all(source, ("家属", "贷款"))
|
| 273 |
+
or (contains_source_any(source, ("借款", "逾期")) and contains_source_any(source, ("母亲", "电话", "儿子")))
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def is_direct_mother_speaker(source: str) -> bool:
|
| 278 |
+
return contains_source_any(source, DIRECT_MOTHER_SPEAKER_MARKERS)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def is_child_to_mother_source(source: str) -> bool:
|
| 282 |
+
return contains_source_any(source, CHILD_TO_MOTHER_SOURCE_TERMS)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def has_sibling_context_source(source: str) -> bool:
|
| 286 |
+
return contains_source_any(source, SIBLING_CONTEXT_SOURCE_TERMS)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def dialogue_suffix_has(source: str, terms: tuple[str, ...]) -> bool:
|
| 290 |
+
return contains_source_any(dialogue_suffix(source), terms)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def is_older_brother_to_younger_source(source: str) -> bool:
|
| 294 |
+
prefix = dialogue_prefix(source)
|
| 295 |
+
if ("我是你哥" in source) or ("我是你哥哥" in source):
|
| 296 |
+
return True
|
| 297 |
+
if contains_source_any(prefix, OLDER_BROTHER_SOURCE_TERMS) and contains_source_any(source, YOUNGER_SISTER_SOURCE_TERMS):
|
| 298 |
+
return True
|
| 299 |
+
return False
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def is_younger_to_older_brother_source(source: str) -> bool:
|
| 303 |
+
prefix = dialogue_prefix(source)
|
| 304 |
+
if contains_source_any(prefix, YOUNGER_SISTER_SOURCE_TERMS) and contains_source_any(source, OLDER_BROTHER_SOURCE_TERMS):
|
| 305 |
+
return True
|
| 306 |
+
return False
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def sibling_reply_tag_from_source(source: str, fallback: str | None = None) -> str | None:
|
| 310 |
+
if is_older_brother_to_younger_source(source):
|
| 311 |
+
return "sibling_older_brother_to_younger"
|
| 312 |
+
if is_younger_to_older_brother_source(source):
|
| 313 |
+
return "sibling_younger_to_older_brother"
|
| 314 |
+
if dialogue_suffix_has(source, ("她", "妹妹")):
|
| 315 |
+
return "sibling_younger_to_older_brother"
|
| 316 |
+
if dialogue_suffix_has(source, ("他", "哥哥")):
|
| 317 |
+
return "sibling_older_brother_to_younger"
|
| 318 |
+
return fallback
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def is_brother_to_sister_source(source: str, text: str) -> bool:
|
| 322 |
+
return "姐" in source or contains_word(text, "chị")
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def is_peer_classmate_source(source: str) -> bool:
|
| 326 |
+
return contains_source_any(source, PEER_CLASSMATE_SOURCE_TERMS)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def is_school_application_source(source: str) -> bool:
|
| 330 |
+
return contains_source_any(source, SCHOOL_APPLICATION_SOURCE_TERMS)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def is_exclusive_institution_we_source(source: str) -> bool:
|
| 334 |
+
return contains_source_any(source, INSTITUTION_WE_SOURCE_TERMS)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def is_institution_applicant_source(source: str) -> bool:
|
| 338 |
+
return "你" in source and contains_source_any(source, INSTITUTION_APPLICANT_SOURCE_TERMS)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def is_institution_applicant_context_source(source: str) -> bool:
|
| 342 |
+
if is_institution_applicant_source(source):
|
| 343 |
+
return True
|
| 344 |
+
if source_has_all(source, ("张羽同学", "学费")):
|
| 345 |
+
return True
|
| 346 |
+
if source_has_all(source, ("同学", "适合", "高中")):
|
| 347 |
+
return True
|
| 348 |
+
return contains_source_any(source, APPLICANT_CONTEXT_SOURCE_TERMS) and has_speech_marker(source)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def is_applicant_self_source(source: str) -> bool:
|
| 352 |
+
if "我" not in source:
|
| 353 |
+
return False
|
| 354 |
+
prefix = dialogue_prefix(source)
|
| 355 |
+
if contains_source_any(prefix, ("面试官", "老师", "主任", "母亲", "妈妈", "老者")):
|
| 356 |
+
return False
|
| 357 |
+
if contains_source_any(prefix, APPLICANT_SELF_SOURCE_TERMS):
|
| 358 |
+
return True
|
| 359 |
+
return contains_source_any(source, ("我想考上", "我会努力", "我已经自学", "我一定会"))
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def is_generic_modern_dialogue_source(source: str) -> bool:
|
| 363 |
+
if not has_speech_marker(source) or is_inner_monologue_source(source):
|
| 364 |
+
return False
|
| 365 |
+
if contains_source_any(source, (*SUPERNATURAL_GUARD_TERMS, *GENERIC_MODERN_DIALOGUE_GUARD_TERMS)):
|
| 366 |
+
return False
|
| 367 |
+
prefix = dialogue_prefix(source)
|
| 368 |
+
if not prefix.strip():
|
| 369 |
+
return False
|
| 370 |
+
if contains_source_any(source, (*MODERN_ROUTE_TERMS, *MODERN_BOOK_HINT_TERMS, *APPLICANT_CONTEXT_SOURCE_TERMS)):
|
| 371 |
+
return True
|
| 372 |
+
return contains_source_any(prefix, ("说", "道", "问", "答", "笑", "叹", "喊", "叫", "面试官", "张羽"))
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def is_likely_mother_message_source(source: str) -> bool:
|
| 376 |
+
stripped = source.lstrip()
|
| 377 |
+
if not (stripped.startswith("“") or stripped.startswith('"')):
|
| 378 |
+
return False
|
| 379 |
+
if contains_source_any(source, (*SUPERNATURAL_GUARD_TERMS, *GENERIC_MODERN_DIALOGUE_GUARD_TERMS)):
|
| 380 |
+
return False
|
| 381 |
+
if contains_source_any(source, ("面试官", "老师", "张羽", "老者", "少年")):
|
| 382 |
+
return False
|
| 383 |
+
return "你" in source or "我" in source
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def infer_policy_tags(source: str, text: str, flags: list[str]) -> tuple[list[str], list[str]]:
|
| 387 |
+
tags: list[str] = []
|
| 388 |
+
guard_reasons: list[str] = []
|
| 389 |
+
forbidden = forbidden_flags(flags)
|
| 390 |
+
has_context_signal = bool(
|
| 391 |
+
forbidden
|
| 392 |
+
or contains_word(text, "cậu")
|
| 393 |
+
or contains_word(text, "chúng ta")
|
| 394 |
+
or school_we_is_exclusive(source, text)
|
| 395 |
+
or is_school_application_source(source)
|
| 396 |
+
or is_exclusive_institution_we_source(source)
|
| 397 |
+
or is_institution_applicant_source(source)
|
| 398 |
+
or is_peer_classmate_source(source)
|
| 399 |
+
or is_child_to_mother_source(source)
|
| 400 |
+
)
|
| 401 |
+
if not has_context_signal:
|
| 402 |
+
return tags, guard_reasons
|
| 403 |
+
|
| 404 |
+
if forbidden and contains_source_any(source, SUPERNATURAL_GUARD_TERMS):
|
| 405 |
+
return ["supernatural_entity"], ["supernatural_or_entity_context"]
|
| 406 |
+
|
| 407 |
+
peer_classmate = is_peer_classmate_source(source)
|
| 408 |
+
if peer_classmate:
|
| 409 |
+
tags.append("peer_classmate")
|
| 410 |
+
|
| 411 |
+
sibling_tag = sibling_reply_tag_from_source(source)
|
| 412 |
+
if sibling_tag:
|
| 413 |
+
tags.append(sibling_tag)
|
| 414 |
+
|
| 415 |
+
if is_teacher_peer_source(source):
|
| 416 |
+
tags.append("teacher_peer")
|
| 417 |
+
elif not peer_classmate and not sibling_tag and is_teacher_to_student_source(source):
|
| 418 |
+
tags.append("teacher_student")
|
| 419 |
+
|
| 420 |
+
source_has_teacher = contains_source_any(source, TEACHER_SOURCE_TERMS)
|
| 421 |
+
source_has_school_context = contains_source_any(source, SCHOOL_CONTEXT_SOURCE_TERMS) and "大学同学" not in source
|
| 422 |
+
if source_has_teacher and "我" in source and contains_word(text, "ta") and "teacher_student" not in tags:
|
| 423 |
+
tags.append("student_teacher")
|
| 424 |
+
elif (
|
| 425 |
+
source_has_school_context
|
| 426 |
+
and any("modern_you" in flag for flag in forbidden)
|
| 427 |
+
and "teacher_student" not in tags
|
| 428 |
+
and "teacher_peer" not in tags
|
| 429 |
+
and "peer_classmate" not in tags
|
| 430 |
+
and not sibling_tag
|
| 431 |
+
):
|
| 432 |
+
tags.append("teacher_student")
|
| 433 |
+
|
| 434 |
+
if is_mother_to_child_source(source):
|
| 435 |
+
tags.append("family_mother_child")
|
| 436 |
+
if is_child_to_mother_source(source):
|
| 437 |
+
tags.append("family_child_to_mother")
|
| 438 |
+
if is_formal_debt_source(source):
|
| 439 |
+
tags.append("formal_debt_call")
|
| 440 |
+
if is_brother_to_sister_source(source, text):
|
| 441 |
+
tags.append("family_brother_to_sister")
|
| 442 |
+
if is_school_application_source(source):
|
| 443 |
+
tags.append("school_application_self")
|
| 444 |
+
if is_exclusive_institution_we_source(source):
|
| 445 |
+
tags.append("institution_we_exclusive")
|
| 446 |
+
if is_institution_applicant_source(source):
|
| 447 |
+
tags.append("institution_applicant")
|
| 448 |
+
if is_applicant_self_source(source) and contains_source_any(source, APPLICANT_CONTEXT_SOURCE_TERMS):
|
| 449 |
+
tags.append("applicant_self")
|
| 450 |
+
return tags, guard_reasons
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def replace_pronoun_matches(
|
| 454 |
+
text: str,
|
| 455 |
+
matches: list[re.Match[str]],
|
| 456 |
+
replacement: str,
|
| 457 |
+
applied: Counter[str],
|
| 458 |
+
rule_name: str,
|
| 459 |
+
) -> str:
|
| 460 |
+
if not matches:
|
| 461 |
+
return text
|
| 462 |
+
out = text
|
| 463 |
+
for match in reversed(matches):
|
| 464 |
+
out = out[: match.start()] + cap_like(match.group(0), replacement) + out[match.end() :]
|
| 465 |
+
applied[rule_name] += len(matches)
|
| 466 |
+
return out
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def add_ta_replacement(text: str, replacement: str, applied: Counter[str], rule_name: str) -> str:
|
| 470 |
+
return replace_pronoun_matches(text, standalone_ta_matches(text), replacement, applied, rule_name)
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def add_toi_replacement(text: str, replacement: str, applied: Counter[str], rule_name: str) -> str:
|
| 474 |
+
matches = standalone_pronoun_matches(text, "tôi", ALLOWED_PREVIOUS_TOI)
|
| 475 |
+
return replace_pronoun_matches(text, matches, replacement, applied, rule_name)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def apply_many_words(text: str, sources: tuple[str, ...], replacement: str, applied: Counter[str], rule_name: str) -> str:
|
| 479 |
+
for source in sources:
|
| 480 |
+
text = apply_replacement(text, source, replacement, applied, rule_name)
|
| 481 |
+
return text
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def apply_third_person_flags(text: str, flags_before: list[str], applied: Counter[str]) -> str:
|
| 485 |
+
if any("mother_third_person_modern" in flag for flag in flags_before):
|
| 486 |
+
return apply_replacement(text, "nàng", "bà", applied, "mother_third_person")
|
| 487 |
+
if any("female_third_person_modern" in flag for flag in flags_before):
|
| 488 |
+
return apply_replacement(text, "nàng", "cô ấy", applied, "female_third_person")
|
| 489 |
+
return text
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def harmonize_stability_row(
|
| 493 |
+
*,
|
| 494 |
+
source: str,
|
| 495 |
+
input_vi: str,
|
| 496 |
+
flags_before: list[str],
|
| 497 |
+
policy_tags: list[str],
|
| 498 |
+
guard_reasons: list[str],
|
| 499 |
+
) -> tuple[str, dict[str, int]]:
|
| 500 |
+
text = input_vi
|
| 501 |
+
applied: Counter[str] = Counter()
|
| 502 |
+
tags = set(policy_tags)
|
| 503 |
+
|
| 504 |
+
if "route:xianxia_copy_guard" in guard_reasons or "route:unknown_copy_guard" in guard_reasons:
|
| 505 |
+
return text, {}
|
| 506 |
+
|
| 507 |
+
guarded = bool(guard_reasons)
|
| 508 |
+
if not guarded:
|
| 509 |
+
if "mother_narration_context" in tags and any("female_third_person_modern" in flag for flag in flags_before):
|
| 510 |
+
text = apply_replacement(text, "nàng", "bà", applied, "mother_third_person")
|
| 511 |
+
else:
|
| 512 |
+
text = apply_third_person_flags(text, flags_before, applied)
|
| 513 |
+
|
| 514 |
+
if "school_application_self" in tags:
|
| 515 |
+
text = apply_replacement(text, "trường chúng tôi", "trường tôi", applied, "school_application_self_school")
|
| 516 |
+
text = apply_replacement(text, "trường ta", "trường tôi", applied, "school_application_self_school")
|
| 517 |
+
text = add_ta_replacement(text, "tôi", applied, "school_application_self")
|
| 518 |
+
|
| 519 |
+
if "institution_we_exclusive" in tags:
|
| 520 |
+
text = apply_replacement(text, "trường chúng ta", "trường chúng tôi", applied, "institution_school_we")
|
| 521 |
+
text = apply_replacement(text, "trường ta", "trường chúng tôi", applied, "institution_school_we")
|
| 522 |
+
text = apply_replacement(text, "chúng ta", "chúng tôi", applied, "institution_we_exclusive")
|
| 523 |
+
if not tags & {"teacher_student", "family_mother_child", "family_sister_to_brother_context", "family_child_to_mother"}:
|
| 524 |
+
text = add_ta_replacement(text, "tôi", applied, "institution_self_as_toi")
|
| 525 |
+
|
| 526 |
+
if "formal_debt_call" in tags:
|
| 527 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các anh", applied, "formal_debt_you")
|
| 528 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "ngài", applied, "formal_debt_you")
|
| 529 |
+
text = apply_replacement(text, "chúng ta", "chúng tôi", applied, "formal_debt_we")
|
| 530 |
+
text = add_ta_replacement(text, "tôi", applied, "formal_debt_self")
|
| 531 |
+
text = add_toi_replacement(text, "tôi", applied, "formal_debt_self")
|
| 532 |
+
return text, dict(applied)
|
| 533 |
+
|
| 534 |
+
if guarded:
|
| 535 |
+
return text, dict(applied)
|
| 536 |
+
|
| 537 |
+
if "sibling_older_brother_to_younger" in tags:
|
| 538 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các em", applied, "older_brother_you_as_em")
|
| 539 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "em", applied, "older_brother_you_as_em")
|
| 540 |
+
text = add_ta_replacement(text, "anh", applied, "older_brother_self_as_anh")
|
| 541 |
+
text = add_toi_replacement(text, "anh", applied, "older_brother_self_as_anh")
|
| 542 |
+
return text, dict(applied)
|
| 543 |
+
|
| 544 |
+
if "sibling_younger_to_older_brother" in tags:
|
| 545 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các anh", applied, "younger_sibling_you_as_anh")
|
| 546 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "anh", applied, "younger_sibling_you_as_anh")
|
| 547 |
+
text = add_ta_replacement(text, "em", applied, "younger_sibling_self_as_em")
|
| 548 |
+
text = add_toi_replacement(text, "em", applied, "younger_sibling_self_as_em")
|
| 549 |
+
return text, dict(applied)
|
| 550 |
+
|
| 551 |
+
if "peer_classmate" in tags:
|
| 552 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các cậu", applied, "peer_you_as_cau")
|
| 553 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "cậu", applied, "peer_you_as_cau")
|
| 554 |
+
text = add_ta_replacement(text, "tớ", applied, "peer_self_as_to")
|
| 555 |
+
if "我欠二十多万的人" in source:
|
| 556 |
+
text = apply_replacement(text, "người ta nợ hơn hai mươi vạn", "người như tớ nợ hơn hai mươi vạn", applied, "peer_debt_self_phrase")
|
| 557 |
+
text = apply_replacement(text, "tớ nợ hơn hai mươi vạn người", "người như tớ nợ hơn hai mươi vạn", applied, "peer_debt_self_phrase")
|
| 558 |
+
return text, dict(applied)
|
| 559 |
+
|
| 560 |
+
if "family_mother_child" in tags:
|
| 561 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các con", applied, "mother_you_as_con")
|
| 562 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "con", applied, "mother_you_as_con")
|
| 563 |
+
text = add_ta_replacement(text, "mẹ", applied, "mother_self_as_me")
|
| 564 |
+
text = add_toi_replacement(text, "mẹ", applied, "mother_self_as_me")
|
| 565 |
+
return text, dict(applied)
|
| 566 |
+
|
| 567 |
+
if "family_child_to_mother" in tags:
|
| 568 |
+
text = apply_many_words(text, ("Mẹ, em yên tâm", "Mẹ, ngươi yên tâm", "Mẹ, cậu yên tâm"), "Mẹ, mẹ yên tâm", applied, "child_you_as_mother")
|
| 569 |
+
text = apply_many_words(text, ("ngươi", "cậu", "ngài"), "mẹ", applied, "child_you_as_mother")
|
| 570 |
+
text = apply_many_words(text, ("chúng ta", "chúng tôi"), "chúng con", applied, "child_we_as_chung_con")
|
| 571 |
+
if "你" not in source or "Mẹ, mẹ yên tâm" in text:
|
| 572 |
+
text = apply_replacement(text, "em", "con", applied, "child_self_as_con")
|
| 573 |
+
text = add_ta_replacement(text, "con", applied, "child_self_as_con")
|
| 574 |
+
text = add_toi_replacement(text, "con", applied, "child_self_as_con")
|
| 575 |
+
return text, dict(applied)
|
| 576 |
+
|
| 577 |
+
if "applicant_self" in tags:
|
| 578 |
+
text = add_ta_replacement(text, "em", applied, "applicant_self_as_em")
|
| 579 |
+
text = add_toi_replacement(text, "em", applied, "applicant_self_as_em")
|
| 580 |
+
|
| 581 |
+
if "institution_applicant" in tags and "teacher_student" not in tags:
|
| 582 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các em", applied, "institution_applicant_you")
|
| 583 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "em", applied, "institution_applicant_you")
|
| 584 |
+
return text, dict(applied)
|
| 585 |
+
|
| 586 |
+
if "applicant_self" in tags and "teacher_student" not in tags:
|
| 587 |
+
return text, dict(applied)
|
| 588 |
+
|
| 589 |
+
if "family_brother_to_sister" in tags:
|
| 590 |
+
text = add_ta_replacement(text, "em", applied, "brother_self_as_em")
|
| 591 |
+
text = add_toi_replacement(text, "em", applied, "brother_self_as_em")
|
| 592 |
+
return text, dict(applied)
|
| 593 |
+
|
| 594 |
+
if "family_sister_to_brother_context" in tags:
|
| 595 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các em", applied, "sister_you_as_em")
|
| 596 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "em", applied, "sister_you_as_em")
|
| 597 |
+
text = add_ta_replacement(text, "chị", applied, "sister_self_as_chi")
|
| 598 |
+
text = add_toi_replacement(text, "chị", applied, "sister_self_as_chi")
|
| 599 |
+
return text, dict(applied)
|
| 600 |
+
|
| 601 |
+
if "teacher_student" in tags:
|
| 602 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các em", applied, "teacher_you_as_student")
|
| 603 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "em", applied, "teacher_you_as_student")
|
| 604 |
+
text = add_ta_replacement(text, "thầy", applied, "teacher_self_as_thay")
|
| 605 |
+
text = add_toi_replacement(text, "thầy", applied, "teacher_self_as_thay")
|
| 606 |
+
text = apply_replacement(text, "lão sư", "giáo viên", applied, "teacher_context_lao_su")
|
| 607 |
+
return text, dict(applied)
|
| 608 |
+
|
| 609 |
+
if "student_teacher" in tags:
|
| 610 |
+
text = add_ta_replacement(text, "em", applied, "student_self_as_em")
|
| 611 |
+
text = add_toi_replacement(text, "em", applied, "student_self_as_em")
|
| 612 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "thầy", applied, "student_you_as_teacher")
|
| 613 |
+
return text, dict(applied)
|
| 614 |
+
|
| 615 |
+
if "generic_modern_dialogue" in tags:
|
| 616 |
+
text = apply_many_words(text, ("các ngươi", "các cậu"), "các cậu", applied, "generic_modern_you")
|
| 617 |
+
text = apply_many_words(text, ("ngươi", "cậu"), "cậu", applied, "generic_modern_you")
|
| 618 |
+
text = add_ta_replacement(text, "tôi", applied, "generic_modern_self")
|
| 619 |
+
return text, dict(applied)
|
| 620 |
+
|
| 621 |
+
return text, dict(applied)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def apply_runtime_row(
|
| 625 |
+
chapter: str,
|
| 626 |
+
system: str,
|
| 627 |
+
row: dict[str, Any],
|
| 628 |
+
route: str,
|
| 629 |
+
route_reason: str,
|
| 630 |
+
context_guard_reasons: list[str] | None = None,
|
| 631 |
+
context_policy_tags: list[str] | None = None,
|
| 632 |
+
) -> dict[str, Any]:
|
| 633 |
+
source = str(row["source_zh"])
|
| 634 |
+
input_vi = str(row["input_vi"])
|
| 635 |
+
flags_before = detect_flags(source, input_vi)
|
| 636 |
+
policy_tags, guard_reasons = infer_policy_tags(source, input_vi, flags_before)
|
| 637 |
+
if context_policy_tags:
|
| 638 |
+
policy_tags = [*policy_tags, *context_policy_tags]
|
| 639 |
+
teacher_window_active = any(tag in TEACHER_WINDOW_TAGS for tag in policy_tags)
|
| 640 |
+
policy_tags = [tag for tag in policy_tags if tag not in TEACHER_WINDOW_TAGS]
|
| 641 |
+
if is_inner_monologue_source(source):
|
| 642 |
+
policy_tags = [tag for tag in policy_tags if tag != "peer_classmate"]
|
| 643 |
+
sibling_policy_tags = {"sibling_older_brother_to_younger", "sibling_younger_to_older_brother"}
|
| 644 |
+
if set(policy_tags) & sibling_policy_tags:
|
| 645 |
+
policy_tags = [
|
| 646 |
+
tag for tag in policy_tags
|
| 647 |
+
if tag not in {"teacher_student", "student_teacher", "teacher_peer", "peer_classmate"}
|
| 648 |
+
]
|
| 649 |
+
if "teacher_student" in policy_tags and (
|
| 650 |
+
"peer_classmate" not in policy_tags or teacher_window_active or is_teacher_speaker_source(source)
|
| 651 |
+
):
|
| 652 |
+
policy_tags = [tag for tag in policy_tags if tag not in {"peer_classmate", "student_teacher"}]
|
| 653 |
+
elif "peer_classmate" in policy_tags:
|
| 654 |
+
policy_tags = [tag for tag in policy_tags if tag not in {"teacher_student", "student_teacher"}]
|
| 655 |
+
if "family_sister_to_brother_context" in policy_tags and not is_direct_mother_speaker(source):
|
| 656 |
+
policy_tags = [tag for tag in policy_tags if tag != "family_mother_child"]
|
| 657 |
+
policy_tags = list(dict.fromkeys(policy_tags))
|
| 658 |
+
|
| 659 |
+
if context_guard_reasons and forbidden_flags(flags_before):
|
| 660 |
+
guard_reasons = [*guard_reasons, *context_guard_reasons]
|
| 661 |
+
if route.endswith("_copy_guard") and flags_before:
|
| 662 |
+
guard_reasons = [*guard_reasons, f"route:{route}"]
|
| 663 |
+
if (
|
| 664 |
+
route == "modern_school"
|
| 665 |
+
and not policy_tags
|
| 666 |
+
and not guard_reasons
|
| 667 |
+
and forbidden_flags(flags_before)
|
| 668 |
+
and is_generic_modern_dialogue_source(source)
|
| 669 |
+
):
|
| 670 |
+
policy_tags.append("generic_modern_dialogue")
|
| 671 |
+
|
| 672 |
+
if route.endswith("_copy_guard"):
|
| 673 |
+
prediction = input_vi
|
| 674 |
+
applied: dict[str, int] = {}
|
| 675 |
+
else:
|
| 676 |
+
prediction, applied = harmonize_stability_row(
|
| 677 |
+
source=source,
|
| 678 |
+
input_vi=input_vi,
|
| 679 |
+
flags_before=flags_before,
|
| 680 |
+
policy_tags=policy_tags,
|
| 681 |
+
guard_reasons=guard_reasons,
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
flags_after = detect_flags(source, prediction)
|
| 685 |
+
actionable_before = actionable_flags(flags_before)
|
| 686 |
+
actionable_after = actionable_flags(flags_after)
|
| 687 |
+
changed = prediction != input_vi
|
| 688 |
+
actionable_policy_tags = not route.endswith("_copy_guard") and has_actionable_policy_tags(policy_tags)
|
| 689 |
+
return {
|
| 690 |
+
"id": f"{chapter}:{system}:{int(row['row_index']):04d}",
|
| 691 |
+
"chapter": chapter,
|
| 692 |
+
"system": system,
|
| 693 |
+
"route": route,
|
| 694 |
+
"route_reason": route_reason,
|
| 695 |
+
"row_index": row["row_index"],
|
| 696 |
+
"source_zh": source,
|
| 697 |
+
"input_vi": input_vi,
|
| 698 |
+
"prediction_vi": prediction,
|
| 699 |
+
"changed": changed,
|
| 700 |
+
"flags_before": flags_before,
|
| 701 |
+
"flags_after": flags_after,
|
| 702 |
+
"actionable_flags_before": actionable_before,
|
| 703 |
+
"actionable_flags_after": actionable_after,
|
| 704 |
+
"policy_tags": policy_tags,
|
| 705 |
+
"guard_reasons": guard_reasons,
|
| 706 |
+
"applied_rules": applied,
|
| 707 |
+
"needs_review": bool(changed or actionable_before or actionable_after or guard_reasons or actionable_policy_tags),
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def apply_runtime_spec_rows(
|
| 712 |
+
chapter: str,
|
| 713 |
+
system: str,
|
| 714 |
+
rows: list[dict[str, Any]],
|
| 715 |
+
route: str,
|
| 716 |
+
route_reason: str,
|
| 717 |
+
) -> tuple[list[dict[str, Any]], int]:
|
| 718 |
+
predictions: list[dict[str, Any]] = []
|
| 719 |
+
missing_inputs = 0
|
| 720 |
+
context_guard_remaining = 0
|
| 721 |
+
sister_to_brother_remaining = 0
|
| 722 |
+
child_to_mother_remaining = 0
|
| 723 |
+
mother_narration_remaining = 0
|
| 724 |
+
peer_classmate_remaining = 0
|
| 725 |
+
teacher_to_student_remaining = 0
|
| 726 |
+
inner_monologue_remaining = 0
|
| 727 |
+
institution_applicant_remaining = 0
|
| 728 |
+
sibling_dialogue_remaining = 0
|
| 729 |
+
sibling_next_policy_tag: str | None = None
|
| 730 |
+
pre_context_policy_tags: list[list[str]] = [[] for _ in rows]
|
| 731 |
+
|
| 732 |
+
for row_pos, row in enumerate(rows):
|
| 733 |
+
source_text = str(row["source_zh"])
|
| 734 |
+
if route != "modern_school" or not contains_source_any(source_text, MOTHER_MESSAGE_CONTEXT_TERMS):
|
| 735 |
+
continue
|
| 736 |
+
for previous_pos in range(max(0, row_pos - 4), row_pos):
|
| 737 |
+
previous_source = str(rows[previous_pos]["source_zh"])
|
| 738 |
+
if is_likely_mother_message_source(previous_source):
|
| 739 |
+
pre_context_policy_tags[previous_pos].append("family_mother_child")
|
| 740 |
+
|
| 741 |
+
for row_pos, row in enumerate(rows):
|
| 742 |
+
source_text = str(row["source_zh"])
|
| 743 |
+
if not str(row["input_vi"]).strip():
|
| 744 |
+
missing_inputs += 1
|
| 745 |
+
context_guard_reasons: list[str] = []
|
| 746 |
+
context_policy_tags: list[str] = [*pre_context_policy_tags[row_pos]]
|
| 747 |
+
explicit_sibling_tag = sibling_reply_tag_from_source(source_text)
|
| 748 |
+
if route == "modern_school" and explicit_sibling_tag:
|
| 749 |
+
context_policy_tags.append(explicit_sibling_tag)
|
| 750 |
+
elif (
|
| 751 |
+
route == "modern_school"
|
| 752 |
+
and sibling_dialogue_remaining > 0
|
| 753 |
+
and sibling_next_policy_tag
|
| 754 |
+
and has_speech_marker(source_text)
|
| 755 |
+
and ("你" in source_text or "我" in source_text)
|
| 756 |
+
):
|
| 757 |
+
context_policy_tags.append(sibling_reply_tag_from_source(source_text, sibling_next_policy_tag) or sibling_next_policy_tag)
|
| 758 |
+
sibling_dialogue_remaining -= 1
|
| 759 |
+
if route == "modern_school" and (
|
| 760 |
+
is_child_to_mother_source(source_text)
|
| 761 |
+
or is_teacher_speaker_source(source_text)
|
| 762 |
+
or is_clear_non_teacher_speaker_source(source_text)
|
| 763 |
+
):
|
| 764 |
+
inner_monologue_remaining = 0
|
| 765 |
+
if route == "modern_school" and institution_applicant_remaining > 0:
|
| 766 |
+
if "你" in source_text or "你们" in source_text:
|
| 767 |
+
context_policy_tags.append("institution_applicant")
|
| 768 |
+
if has_speech_marker(source_text) and is_applicant_self_source(source_text):
|
| 769 |
+
context_policy_tags.append("applicant_self")
|
| 770 |
+
institution_applicant_remaining -= 1
|
| 771 |
+
if route == "modern_school" and context_guard_remaining > 0:
|
| 772 |
+
context_guard_reasons.append("context:supernatural_entity_window")
|
| 773 |
+
context_guard_remaining -= 1
|
| 774 |
+
if route == "modern_school" and inner_monologue_remaining > 0:
|
| 775 |
+
if has_speech_marker(source_text):
|
| 776 |
+
context_guard_reasons.append("context:inner_monologue_window")
|
| 777 |
+
inner_monologue_remaining -= 1
|
| 778 |
+
if route == "modern_school" and sister_to_brother_remaining > 0:
|
| 779 |
+
if "你" in source_text or "我" in source_text:
|
| 780 |
+
context_policy_tags.append("family_sister_to_brother_context")
|
| 781 |
+
sister_to_brother_remaining -= 1
|
| 782 |
+
if route == "modern_school" and child_to_mother_remaining > 0:
|
| 783 |
+
if "你" in source_text or "我" in source_text:
|
| 784 |
+
context_policy_tags.append("family_child_to_mother")
|
| 785 |
+
child_to_mother_remaining -= 1
|
| 786 |
+
if route == "modern_school" and mother_narration_remaining > 0:
|
| 787 |
+
if "她" in source_text:
|
| 788 |
+
context_policy_tags.append("mother_narration_context")
|
| 789 |
+
mother_narration_remaining -= 1
|
| 790 |
+
if route == "modern_school" and peer_classmate_remaining > 0:
|
| 791 |
+
if ("你" in source_text or "我" in source_text) and has_speech_marker(source_text):
|
| 792 |
+
context_policy_tags.append("peer_classmate")
|
| 793 |
+
peer_classmate_remaining -= 1
|
| 794 |
+
if route == "modern_school" and teacher_to_student_remaining > 0:
|
| 795 |
+
if is_clear_non_teacher_speaker_source(source_text):
|
| 796 |
+
teacher_to_student_remaining = 0
|
| 797 |
+
else:
|
| 798 |
+
if has_speech_marker(source_text):
|
| 799 |
+
if "你" in source_text:
|
| 800 |
+
context_policy_tags.extend(["teacher_student", "teacher_student_window"])
|
| 801 |
+
elif "我" in source_text:
|
| 802 |
+
context_policy_tags.extend(["student_teacher", "student_teacher_window"])
|
| 803 |
+
teacher_to_student_remaining -= 1
|
| 804 |
+
|
| 805 |
+
predictions.append(
|
| 806 |
+
apply_runtime_row(
|
| 807 |
+
chapter,
|
| 808 |
+
system,
|
| 809 |
+
row,
|
| 810 |
+
route,
|
| 811 |
+
route_reason,
|
| 812 |
+
context_guard_reasons,
|
| 813 |
+
context_policy_tags,
|
| 814 |
+
)
|
| 815 |
+
)
|
| 816 |
+
|
| 817 |
+
if route == "modern_school" and contains_source_any(source_text, SUPERNATURAL_CONTEXT_TERMS):
|
| 818 |
+
context_guard_remaining = max(context_guard_remaining, 2)
|
| 819 |
+
if route == "modern_school" and is_inner_monologue_source(source_text):
|
| 820 |
+
inner_monologue_remaining = max(inner_monologue_remaining, 2)
|
| 821 |
+
if route == "modern_school" and "姐" in source_text:
|
| 822 |
+
sister_to_brother_remaining = max(sister_to_brother_remaining, 4)
|
| 823 |
+
if route == "modern_school" and is_child_to_mother_source(source_text):
|
| 824 |
+
child_to_mother_remaining = max(child_to_mother_remaining, 4)
|
| 825 |
+
if route == "modern_school" and contains_source_any(source_text, MOTHER_SOURCE_TERMS):
|
| 826 |
+
mother_narration_remaining = max(mother_narration_remaining, 18)
|
| 827 |
+
if route == "modern_school" and is_teacher_speaker_source(source_text):
|
| 828 |
+
teacher_to_student_remaining = max(teacher_to_student_remaining, 12)
|
| 829 |
+
if route == "modern_school" and is_peer_classmate_source(source_text) and has_speech_marker(source_text):
|
| 830 |
+
peer_classmate_remaining = max(peer_classmate_remaining, 10)
|
| 831 |
+
if route == "modern_school" and is_institution_applicant_context_source(source_text):
|
| 832 |
+
institution_applicant_remaining = max(institution_applicant_remaining, 12)
|
| 833 |
+
if route == "modern_school":
|
| 834 |
+
row_policy_tags = set(predictions[-1].get("policy_tags") or [])
|
| 835 |
+
if "sibling_older_brother_to_younger" in row_policy_tags:
|
| 836 |
+
sibling_next_policy_tag = "sibling_younger_to_older_brother"
|
| 837 |
+
sibling_dialogue_remaining = max(sibling_dialogue_remaining, 4)
|
| 838 |
+
elif "sibling_younger_to_older_brother" in row_policy_tags:
|
| 839 |
+
sibling_next_policy_tag = "sibling_older_brother_to_younger"
|
| 840 |
+
sibling_dialogue_remaining = max(sibling_dialogue_remaining, 4)
|
| 841 |
+
return predictions, missing_inputs
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
def classify_route_for_rows(rows: list[tuple[int, str, str]], forced_route: str = "auto") -> tuple[str, str]:
|
| 845 |
+
if forced_route != "auto":
|
| 846 |
+
if forced_route not in ROUTES:
|
| 847 |
+
raise ValueError(f"unknown route: {forced_route}")
|
| 848 |
+
return forced_route, f"forced:{forced_route}"
|
| 849 |
+
decision = classify_genre(rows)
|
| 850 |
+
return v9_route_for_decision(decision), f"auto:{decision.reason}"
|
| 851 |
+
|
| 852 |
+
|
| 853 |
+
def summarize_predictions(predictions: list[dict[str, Any]], missing_input_rows: int) -> dict[str, Any]:
|
| 854 |
+
route_counts = Counter(str(row.get("route") or "") for row in predictions)
|
| 855 |
+
applied = Counter()
|
| 856 |
+
policy_tags = Counter()
|
| 857 |
+
guard_reasons = Counter()
|
| 858 |
+
for row in predictions:
|
| 859 |
+
applied.update(row.get("applied_rules") or {})
|
| 860 |
+
policy_tags.update(row.get("policy_tags") or [])
|
| 861 |
+
guard_reasons.update(row.get("guard_reasons") or [])
|
| 862 |
+
return {
|
| 863 |
+
"candidate_id": "pronoun-harmonizer-runtime-v9",
|
| 864 |
+
"rows": len(predictions),
|
| 865 |
+
"missing_input_rows": missing_input_rows,
|
| 866 |
+
"changed_rows": sum(1 for row in predictions if row.get("changed")),
|
| 867 |
+
"needs_review_rows": sum(1 for row in predictions if row.get("needs_review")),
|
| 868 |
+
"guarded_rows": sum(1 for row in predictions if row.get("guard_reasons")),
|
| 869 |
+
"actionable_after_rows": sum(1 for row in predictions if row.get("actionable_flags_after")),
|
| 870 |
+
"route_counts": dict(route_counts),
|
| 871 |
+
"applied_rule_counts": dict(applied),
|
| 872 |
+
"policy_tag_counts": dict(policy_tags),
|
| 873 |
+
"guard_counts": dict(guard_reasons),
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
def harmonize_pronouns_v9(
|
| 878 |
+
rows: list[tuple[int, str, str]],
|
| 879 |
+
*,
|
| 880 |
+
route: str = "auto",
|
| 881 |
+
) -> tuple[list[tuple[int, str, str]], dict[str, Any]]:
|
| 882 |
+
"""Apply V9 to UI translation rows.
|
| 883 |
+
|
| 884 |
+
Input/output row shape is `(index, source_zh, translation_vi)`.
|
| 885 |
+
"""
|
| 886 |
+
if not rows:
|
| 887 |
+
return rows, summarize_predictions([], 0)
|
| 888 |
+
selected_route, route_reason = classify_route_for_rows(rows, route)
|
| 889 |
+
runtime_rows = [
|
| 890 |
+
{"id": f"qt2_{index:04d}", "row_index": index, "source_zh": source, "input_vi": vi}
|
| 891 |
+
for index, source, vi in rows
|
| 892 |
+
]
|
| 893 |
+
predictions, missing = apply_runtime_spec_rows(
|
| 894 |
+
"qt2",
|
| 895 |
+
"ui",
|
| 896 |
+
runtime_rows,
|
| 897 |
+
selected_route,
|
| 898 |
+
route_reason,
|
| 899 |
+
)
|
| 900 |
+
out_rows = [
|
| 901 |
+
(index, source, str(prediction.get("prediction_vi") or vi))
|
| 902 |
+
for (index, source, vi), prediction in zip(rows, predictions)
|
| 903 |
+
]
|
| 904 |
+
report = summarize_predictions(predictions, missing)
|
| 905 |
+
report.update({"enabled": True, "route": selected_route, "route_reason": route_reason})
|
| 906 |
+
return out_rows, report
|
src/text_preprocess.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chinese text normalization before translation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
|
| 7 |
+
NORMALIZE_AUTO = "auto"
|
| 8 |
+
NORMALIZE_T2S = "t2s"
|
| 9 |
+
NORMALIZE_NONE = "none"
|
| 10 |
+
NORMALIZE_MODES = {NORMALIZE_AUTO, NORMALIZE_T2S, NORMALIZE_NONE}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@lru_cache(maxsize=1)
|
| 14 |
+
def _t2s_converter():
|
| 15 |
+
from opencc import OpenCC
|
| 16 |
+
|
| 17 |
+
return OpenCC("t2s")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def normalize_mode(mode: str | None) -> str:
|
| 21 |
+
mode = (mode or NORMALIZE_AUTO).strip().lower()
|
| 22 |
+
return mode if mode in NORMALIZE_MODES else NORMALIZE_AUTO
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def normalize_chinese_text(text: str, mode: str | None = NORMALIZE_AUTO) -> str:
|
| 26 |
+
mode = normalize_mode(mode)
|
| 27 |
+
if mode == NORMALIZE_NONE or not text:
|
| 28 |
+
return text
|
| 29 |
+
return _t2s_converter().convert(text)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def normalization_message(original: str, normalized: str, mode: str | None) -> str:
|
| 33 |
+
mode = normalize_mode(mode)
|
| 34 |
+
if mode == NORMALIZE_NONE:
|
| 35 |
+
return "Giữ nguyên chữ Hán gốc."
|
| 36 |
+
if original == normalized:
|
| 37 |
+
return "Đã kiểm tra chữ Hán: không cần chuyển phồn thể."
|
| 38 |
+
return "Đã chuyển phồn thể sang giản thể trước khi dịch."
|
src/token_chunker.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token-aware chunking for Marian models (ported from HachimiMT HF Space)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
if TYPE_CHECKING:
|
| 9 |
+
from transformers import PreTrainedTokenizerBase
|
| 10 |
+
|
| 11 |
+
SENTENCE_RE = re.compile(r"[^。!?!?;;]+[。!?!?;;]*")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def source_token_ids(
|
| 15 |
+
tokenizer: PreTrainedTokenizerBase,
|
| 16 |
+
text: str,
|
| 17 |
+
*,
|
| 18 |
+
max_length: int,
|
| 19 |
+
truncation: bool,
|
| 20 |
+
) -> list[int]:
|
| 21 |
+
token_ids = tokenizer(
|
| 22 |
+
text,
|
| 23 |
+
truncation=truncation,
|
| 24 |
+
max_length=max_length,
|
| 25 |
+
)["input_ids"]
|
| 26 |
+
if tokenizer.pad_token_id is not None:
|
| 27 |
+
token_ids = [tid for tid in token_ids if tid != tokenizer.pad_token_id]
|
| 28 |
+
return token_ids
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def source_token_count(
|
| 32 |
+
tokenizer: PreTrainedTokenizerBase,
|
| 33 |
+
text: str,
|
| 34 |
+
*,
|
| 35 |
+
max_length: int,
|
| 36 |
+
) -> int:
|
| 37 |
+
return len(source_token_ids(tokenizer, text, max_length=max_length, truncation=False))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def char_chunks(
|
| 41 |
+
tokenizer: PreTrainedTokenizerBase,
|
| 42 |
+
text: str,
|
| 43 |
+
*,
|
| 44 |
+
max_tokens: int,
|
| 45 |
+
) -> list[str]:
|
| 46 |
+
chunks: list[str] = []
|
| 47 |
+
remaining = text
|
| 48 |
+
while remaining:
|
| 49 |
+
if source_token_count(tokenizer, remaining, max_length=max_tokens) <= max_tokens:
|
| 50 |
+
chunks.append(remaining)
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
low, high = 1, len(remaining)
|
| 54 |
+
best = 1
|
| 55 |
+
while low <= high:
|
| 56 |
+
middle = (low + high) // 2
|
| 57 |
+
candidate = remaining[:middle]
|
| 58 |
+
if source_token_count(tokenizer, candidate, max_length=max_tokens) <= max_tokens:
|
| 59 |
+
best = middle
|
| 60 |
+
low = middle + 1
|
| 61 |
+
else:
|
| 62 |
+
high = middle - 1
|
| 63 |
+
|
| 64 |
+
chunks.append(remaining[:best])
|
| 65 |
+
remaining = remaining[best:]
|
| 66 |
+
return chunks
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def sentence_chunks(
|
| 70 |
+
tokenizer: PreTrainedTokenizerBase,
|
| 71 |
+
line: str,
|
| 72 |
+
*,
|
| 73 |
+
max_tokens: int,
|
| 74 |
+
) -> list[str]:
|
| 75 |
+
if source_token_count(tokenizer, line, max_length=max_tokens) <= max_tokens:
|
| 76 |
+
return [line]
|
| 77 |
+
|
| 78 |
+
pieces = [match.group(0) for match in SENTENCE_RE.finditer(line)]
|
| 79 |
+
if not pieces:
|
| 80 |
+
return char_chunks(tokenizer, line, max_tokens=max_tokens)
|
| 81 |
+
|
| 82 |
+
chunks: list[str] = []
|
| 83 |
+
current = ""
|
| 84 |
+
for piece in pieces:
|
| 85 |
+
if source_token_count(tokenizer, piece, max_length=max_tokens) > max_tokens:
|
| 86 |
+
if current:
|
| 87 |
+
chunks.append(current)
|
| 88 |
+
current = ""
|
| 89 |
+
chunks.extend(char_chunks(tokenizer, piece, max_tokens=max_tokens))
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
candidate = current + piece
|
| 93 |
+
if current and source_token_count(tokenizer, candidate, max_length=max_tokens) > max_tokens:
|
| 94 |
+
chunks.append(current)
|
| 95 |
+
current = piece
|
| 96 |
+
else:
|
| 97 |
+
current = candidate
|
| 98 |
+
|
| 99 |
+
if current:
|
| 100 |
+
chunks.append(current)
|
| 101 |
+
return chunks
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def split_for_translation(
|
| 105 |
+
tokenizer: PreTrainedTokenizerBase,
|
| 106 |
+
text: str,
|
| 107 |
+
*,
|
| 108 |
+
max_tokens: int,
|
| 109 |
+
chunk_mode: str = "sentence",
|
| 110 |
+
) -> list[str]:
|
| 111 |
+
"""Split *text* into chunks that fit within *max_tokens*."""
|
| 112 |
+
text = text.strip()
|
| 113 |
+
if not text:
|
| 114 |
+
return []
|
| 115 |
+
|
| 116 |
+
if chunk_mode == "paragraph":
|
| 117 |
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n+", text) if p.strip()]
|
| 118 |
+
chunks: list[str] = []
|
| 119 |
+
for paragraph in paragraphs:
|
| 120 |
+
for line in paragraph.splitlines():
|
| 121 |
+
line = line.strip()
|
| 122 |
+
if not line:
|
| 123 |
+
continue
|
| 124 |
+
chunks.extend(sentence_chunks(tokenizer, line, max_tokens=max_tokens))
|
| 125 |
+
return chunks
|
| 126 |
+
|
| 127 |
+
chunks = []
|
| 128 |
+
for line in text.splitlines():
|
| 129 |
+
line = line.strip()
|
| 130 |
+
if not line:
|
| 131 |
+
continue
|
| 132 |
+
chunks.extend(sentence_chunks(tokenizer, line, max_tokens=max_tokens))
|
| 133 |
+
return chunks
|
src/translator.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HachimiMT Marian translation backend."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from concurrent.futures import Future, ThreadPoolExecutor
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Callable, Iterator
|
| 12 |
+
|
| 13 |
+
import ctranslate2
|
| 14 |
+
import sentencepiece as spm
|
| 15 |
+
from huggingface_hub import snapshot_download
|
| 16 |
+
|
| 17 |
+
from chunker import split_chunks
|
| 18 |
+
from hardware import HardwareProfile, detect_hardware_profile
|
| 19 |
+
from token_chunker import source_token_ids, split_for_translation
|
| 20 |
+
|
| 21 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 22 |
+
MODELS_DIR = Path(os.environ.get("HACHIMIMT_MODELS_DIR", ROOT / "models"))
|
| 23 |
+
SPECIAL_ID_TO_TOKEN = {0: "<pad>", 1: "<s>", 2: "</s>", 3: "<unk>"}
|
| 24 |
+
SPECIAL_TOKEN_TO_ID = {token: token_id for token_id, token in SPECIAL_ID_TO_TOKEN.items()}
|
| 25 |
+
EOS_TOKEN_ID = 2
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Backend(str, Enum):
|
| 29 |
+
CT2 = "ct2"
|
| 30 |
+
TRANSFORMERS = "transformers"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class ModelConfig:
|
| 35 |
+
label: str
|
| 36 |
+
model_id: str
|
| 37 |
+
use_marian_class: bool
|
| 38 |
+
generate_kwargs: dict
|
| 39 |
+
ct2_max_input_tokens: int
|
| 40 |
+
ct2_max_output_tokens: int
|
| 41 |
+
ct2_max_batch_size: int = 8
|
| 42 |
+
default_beam: int = 2
|
| 43 |
+
# Dung lượng xấp xỉ bản CT2 INT8 (MB) — chỉ để hiển thị badge "sẽ tải ~XMB".
|
| 44 |
+
# Khai khi thêm model mới; để None thì badge chỉ hiện "chưa tải" không kèm số.
|
| 45 |
+
ct2_size_mb: int | None = None
|
| 46 |
+
# Tên thư mục con chứa bản CT2 trên repo HF. Mặc định "ct2-int8_float32";
|
| 47 |
+
# một số repo dùng tên khác (vd "ct2-int8"), khai lại ở đây cho từng model.
|
| 48 |
+
ct2_subdir: str = "ct2-int8_float32"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
MODELS: dict[str, ModelConfig] = {
|
| 52 |
+
"HachimiMT-60": ModelConfig(
|
| 53 |
+
label="HachimiMT-60",
|
| 54 |
+
model_id="ngocdang83/HachimiMT-60-zh-vi",
|
| 55 |
+
use_marian_class=True,
|
| 56 |
+
generate_kwargs={
|
| 57 |
+
"max_new_tokens": 300,
|
| 58 |
+
"no_repeat_ngram_size": 2,
|
| 59 |
+
"repetition_penalty": 1.2,
|
| 60 |
+
},
|
| 61 |
+
ct2_max_input_tokens=256,
|
| 62 |
+
ct2_max_output_tokens=300,
|
| 63 |
+
default_beam=2,
|
| 64 |
+
ct2_size_mb=57,
|
| 65 |
+
),
|
| 66 |
+
"HachimiMT-30": ModelConfig(
|
| 67 |
+
label="HachimiMT-30",
|
| 68 |
+
model_id="ngocdang83/HachimiMT-30-zh-vi",
|
| 69 |
+
use_marian_class=False,
|
| 70 |
+
generate_kwargs={
|
| 71 |
+
"max_length": 512,
|
| 72 |
+
},
|
| 73 |
+
ct2_max_input_tokens=512,
|
| 74 |
+
ct2_max_output_tokens=512,
|
| 75 |
+
default_beam=1,
|
| 76 |
+
ct2_size_mb=35,
|
| 77 |
+
),
|
| 78 |
+
"MoxhiMT-60": ModelConfig(
|
| 79 |
+
label="MoxhiMT-60",
|
| 80 |
+
model_id="DanVP/MoxhiMT-60",
|
| 81 |
+
use_marian_class=True,
|
| 82 |
+
generate_kwargs={
|
| 83 |
+
"max_new_tokens": 300,
|
| 84 |
+
"no_repeat_ngram_size": 2,
|
| 85 |
+
"repetition_penalty": 1.2,
|
| 86 |
+
},
|
| 87 |
+
ct2_max_input_tokens=256,
|
| 88 |
+
ct2_max_output_tokens=300,
|
| 89 |
+
default_beam=2,
|
| 90 |
+
ct2_size_mb=58,
|
| 91 |
+
ct2_subdir="ct2-int8", # repo này dùng tên thư mục CT2 khác
|
| 92 |
+
),
|
| 93 |
+
"MoxhiMT-30": ModelConfig(
|
| 94 |
+
label="MoxhiMT-30",
|
| 95 |
+
model_id="DanVP/MoxhiMT-30",
|
| 96 |
+
use_marian_class=True,
|
| 97 |
+
generate_kwargs={
|
| 98 |
+
"max_new_tokens": 300,
|
| 99 |
+
"no_repeat_ngram_size": 2,
|
| 100 |
+
"repetition_penalty": 1.2,
|
| 101 |
+
},
|
| 102 |
+
ct2_max_input_tokens=512,
|
| 103 |
+
ct2_max_output_tokens=512,
|
| 104 |
+
default_beam=1,
|
| 105 |
+
ct2_size_mb=38,
|
| 106 |
+
),
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# Model tải sẵn khi chạy setup (dùng được ngay); các model khác lazy-download.
|
| 110 |
+
DEFAULT_MODEL_KEY = "HachimiMT-60"
|
| 111 |
+
|
| 112 |
+
# Thư mục CT2 mặc định; model nào khác thì khai ModelConfig.ct2_subdir.
|
| 113 |
+
DEFAULT_CT2_SUBDIR = "ct2-int8_float32"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _ct2_download_patterns(config: ModelConfig) -> list[str]:
|
| 117 |
+
return [
|
| 118 |
+
"config.json",
|
| 119 |
+
"source.spm",
|
| 120 |
+
"target.spm",
|
| 121 |
+
"vocab.json",
|
| 122 |
+
"tokenizer_config.json",
|
| 123 |
+
f"{config.ct2_subdir}/*",
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
SourceTokenJobs = list[Future[list[list[str]]]]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _env_int(name: str, default: int, *, min_value: int = 1, max_value: int = 1024) -> int:
|
| 131 |
+
raw = os.environ.get(name, "").strip()
|
| 132 |
+
if not raw:
|
| 133 |
+
return default
|
| 134 |
+
try:
|
| 135 |
+
return max(min_value, min(max_value, int(raw)))
|
| 136 |
+
except ValueError:
|
| 137 |
+
return default
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _batched(items: list[str], size: int) -> Iterator[list[str]]:
|
| 141 |
+
for start in range(0, len(items), size):
|
| 142 |
+
yield items[start : start + size]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def default_ct2_compute_type(device: str) -> str:
|
| 146 |
+
env_compute_type = os.environ.get("HACHIMIMT_COMPUTE_TYPE", "").strip()
|
| 147 |
+
if env_compute_type:
|
| 148 |
+
return env_compute_type
|
| 149 |
+
return "int8_float16" if device == "cuda" else "int8_float32"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@lru_cache(maxsize=1)
|
| 153 |
+
def _optional_torch():
|
| 154 |
+
try:
|
| 155 |
+
import torch
|
| 156 |
+
except Exception:
|
| 157 |
+
return None
|
| 158 |
+
return torch
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _require_torch():
|
| 162 |
+
torch = _optional_torch()
|
| 163 |
+
if torch is None:
|
| 164 |
+
raise RuntimeError(
|
| 165 |
+
"Backend PyTorch cần cài torch. Engine mặc định CTranslate2 không cần torch. "
|
| 166 |
+
"Nếu muốn dùng PyTorch, cài torch rồi cài: pip install -r requirements-pytorch.txt"
|
| 167 |
+
)
|
| 168 |
+
return torch
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _torch_cuda_available() -> bool:
|
| 172 |
+
torch = _optional_torch()
|
| 173 |
+
if torch is None:
|
| 174 |
+
return False
|
| 175 |
+
try:
|
| 176 |
+
return bool(torch.cuda.is_available())
|
| 177 |
+
except Exception:
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _torch_cuda_device_name() -> str | None:
|
| 182 |
+
torch = _optional_torch()
|
| 183 |
+
if torch is None:
|
| 184 |
+
return None
|
| 185 |
+
try:
|
| 186 |
+
if torch.cuda.is_available():
|
| 187 |
+
return str(torch.cuda.get_device_name(0))
|
| 188 |
+
except Exception:
|
| 189 |
+
return None
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _torch_empty_cuda_cache() -> None:
|
| 194 |
+
torch = _optional_torch()
|
| 195 |
+
if torch is None:
|
| 196 |
+
return
|
| 197 |
+
try:
|
| 198 |
+
if torch.cuda.is_available():
|
| 199 |
+
torch.cuda.empty_cache()
|
| 200 |
+
except Exception:
|
| 201 |
+
return
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
class CT2SentencePieceTokenizer:
|
| 205 |
+
"""Minimal Marian SentencePiece tokenizer for CTranslate2 inference."""
|
| 206 |
+
|
| 207 |
+
pad_token_id = 0
|
| 208 |
+
|
| 209 |
+
def __init__(self, model_path: Path) -> None:
|
| 210 |
+
self._source_sp = spm.SentencePieceProcessor(model_file=str(model_path / "source.spm"))
|
| 211 |
+
self._target_sp = spm.SentencePieceProcessor(model_file=str(model_path / "target.spm"))
|
| 212 |
+
|
| 213 |
+
def _encode_one(
|
| 214 |
+
self,
|
| 215 |
+
text: str,
|
| 216 |
+
*,
|
| 217 |
+
truncation: bool = False,
|
| 218 |
+
max_length: int | None = None,
|
| 219 |
+
) -> list[int]:
|
| 220 |
+
token_ids = list(self._source_sp.encode(text, out_type=int))
|
| 221 |
+
token_ids.append(EOS_TOKEN_ID)
|
| 222 |
+
if truncation and max_length is not None and len(token_ids) > max_length:
|
| 223 |
+
token_ids = token_ids[:max_length]
|
| 224 |
+
if token_ids:
|
| 225 |
+
token_ids[-1] = EOS_TOKEN_ID
|
| 226 |
+
return token_ids
|
| 227 |
+
|
| 228 |
+
def __call__(
|
| 229 |
+
self,
|
| 230 |
+
text_or_texts: str | list[str],
|
| 231 |
+
*,
|
| 232 |
+
truncation: bool = False,
|
| 233 |
+
max_length: int | None = None,
|
| 234 |
+
padding: bool = False,
|
| 235 |
+
) -> dict[str, list[int] | list[list[int]]]:
|
| 236 |
+
del padding
|
| 237 |
+
if isinstance(text_or_texts, str):
|
| 238 |
+
return {
|
| 239 |
+
"input_ids": self._encode_one(
|
| 240 |
+
text_or_texts,
|
| 241 |
+
truncation=truncation,
|
| 242 |
+
max_length=max_length,
|
| 243 |
+
)
|
| 244 |
+
}
|
| 245 |
+
return {
|
| 246 |
+
"input_ids": [
|
| 247 |
+
self._encode_one(text, truncation=truncation, max_length=max_length)
|
| 248 |
+
for text in text_or_texts
|
| 249 |
+
]
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
def convert_ids_to_tokens(self, token_ids: list[int]) -> list[str]:
|
| 253 |
+
tokens: list[str] = []
|
| 254 |
+
for token_id in token_ids:
|
| 255 |
+
if token_id in SPECIAL_ID_TO_TOKEN:
|
| 256 |
+
tokens.append(SPECIAL_ID_TO_TOKEN[token_id])
|
| 257 |
+
else:
|
| 258 |
+
tokens.append(self._source_sp.id_to_piece(int(token_id)))
|
| 259 |
+
return tokens
|
| 260 |
+
|
| 261 |
+
def convert_tokens_to_ids(self, tokens: list[str]) -> list[int]:
|
| 262 |
+
token_ids: list[int] = []
|
| 263 |
+
for token in tokens:
|
| 264 |
+
if token in SPECIAL_TOKEN_TO_ID:
|
| 265 |
+
token_ids.append(SPECIAL_TOKEN_TO_ID[token])
|
| 266 |
+
else:
|
| 267 |
+
token_ids.append(int(self._target_sp.piece_to_id(token)))
|
| 268 |
+
return token_ids
|
| 269 |
+
|
| 270 |
+
def decode(self, token_ids: list[int], *, skip_special_tokens: bool = True) -> str:
|
| 271 |
+
return self.batch_decode([token_ids], skip_special_tokens=skip_special_tokens)[0]
|
| 272 |
+
|
| 273 |
+
def batch_decode(
|
| 274 |
+
self,
|
| 275 |
+
token_ids_batch: list[list[int]],
|
| 276 |
+
*,
|
| 277 |
+
skip_special_tokens: bool = True,
|
| 278 |
+
) -> list[str]:
|
| 279 |
+
decoded: list[str] = []
|
| 280 |
+
for token_ids in token_ids_batch:
|
| 281 |
+
if skip_special_tokens:
|
| 282 |
+
token_ids = [
|
| 283 |
+
token_id
|
| 284 |
+
for token_id in token_ids
|
| 285 |
+
if token_id not in SPECIAL_ID_TO_TOKEN
|
| 286 |
+
]
|
| 287 |
+
pieces = [self._target_sp.id_to_piece(int(token_id)) for token_id in token_ids]
|
| 288 |
+
decoded.append(self._target_sp.decode(pieces))
|
| 289 |
+
return decoded
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def model_local_dir(config: ModelConfig) -> Path:
|
| 293 |
+
return MODELS_DIR / config.model_id.split("/")[-1]
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _ct2_ready(path: Path, ct2_subdir: str = DEFAULT_CT2_SUBDIR) -> bool:
|
| 297 |
+
ct2_path = path / ct2_subdir
|
| 298 |
+
return ct2_path.is_dir() and any(ct2_path.iterdir())
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _pytorch_ready(path: Path) -> bool:
|
| 302 |
+
return any(path.glob("*.safetensors")) or any(path.glob("pytorch_model*.bin"))
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _tokenizer_ready(path: Path) -> bool:
|
| 306 |
+
return (path / "source.spm").exists() or (path / "tokenizer_config.json").exists()
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def is_model_downloaded(model_key: str, backend: Backend | str = Backend.CT2) -> bool:
|
| 310 |
+
"""Model (theo backend) đã có sẵn trong MODELS_DIR chưa — để UI hiện badge.
|
| 311 |
+
|
| 312 |
+
Dùng đúng điều kiện mà ensure_model_files() kiểm tra, nên kết quả khớp với
|
| 313 |
+
việc bấm Dịch có phải tải hay không.
|
| 314 |
+
"""
|
| 315 |
+
if isinstance(backend, str):
|
| 316 |
+
backend = Backend(backend)
|
| 317 |
+
if model_key not in MODELS:
|
| 318 |
+
return False
|
| 319 |
+
config = MODELS[model_key]
|
| 320 |
+
path = model_local_dir(config)
|
| 321 |
+
if backend == Backend.CT2:
|
| 322 |
+
weights_ready = _ct2_ready(path, config.ct2_subdir)
|
| 323 |
+
else:
|
| 324 |
+
weights_ready = _pytorch_ready(path)
|
| 325 |
+
return weights_ready and _tokenizer_ready(path)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def ensure_model_files(config: ModelConfig, backend: Backend) -> Path:
|
| 329 |
+
"""Download model vào MODELS_DIR nếu chưa có."""
|
| 330 |
+
local_dir = model_local_dir(config)
|
| 331 |
+
local_dir.mkdir(parents=True, exist_ok=True)
|
| 332 |
+
|
| 333 |
+
if backend == Backend.CT2:
|
| 334 |
+
if _ct2_ready(local_dir, config.ct2_subdir) and _tokenizer_ready(local_dir):
|
| 335 |
+
return local_dir
|
| 336 |
+
patterns = _ct2_download_patterns(config)
|
| 337 |
+
else:
|
| 338 |
+
if _pytorch_ready(local_dir) and _tokenizer_ready(local_dir):
|
| 339 |
+
return local_dir
|
| 340 |
+
patterns = None
|
| 341 |
+
|
| 342 |
+
snapshot_download(
|
| 343 |
+
config.model_id,
|
| 344 |
+
local_dir=str(local_dir),
|
| 345 |
+
allow_patterns=patterns,
|
| 346 |
+
)
|
| 347 |
+
return local_dir
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def download_all_models(*, include_pytorch_weights: bool = False) -> list[Path]:
|
| 351 |
+
"""Tải trước tất cả model vào MODELS_DIR (dùng cho setup.bat).
|
| 352 |
+
|
| 353 |
+
Mặc định chỉ tải bản CT2 INT8 (~95 MB tổng) vì đó là engine mặc định.
|
| 354 |
+
PyTorch weights (~nặng gấp ~4 lần) chỉ tải khi include_pytorch_weights=True
|
| 355 |
+
hoặc tự động khi người dùng đổi sang engine PyTorch lần đầu (ensure_model_files).
|
| 356 |
+
"""
|
| 357 |
+
saved: list[Path] = []
|
| 358 |
+
for config in MODELS.values():
|
| 359 |
+
ensure_model_files(config, Backend.CT2)
|
| 360 |
+
saved.append(model_local_dir(config))
|
| 361 |
+
if include_pytorch_weights:
|
| 362 |
+
ensure_model_files(config, Backend.TRANSFORMERS)
|
| 363 |
+
return saved
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
class HachimiTranslator:
|
| 367 |
+
def __init__(self, profile: HardwareProfile | None = None) -> None:
|
| 368 |
+
self._profile = profile or detect_hardware_profile()
|
| 369 |
+
self._torch_device = "cuda" if _torch_cuda_available() else "cpu"
|
| 370 |
+
self._model_key: str | None = None
|
| 371 |
+
self._backend: Backend | None = None
|
| 372 |
+
self._tokenizer = None
|
| 373 |
+
self._torch_model = None
|
| 374 |
+
self._ct2_model = None
|
| 375 |
+
self._model_path: Path | None = None
|
| 376 |
+
self._ct2_threads = self._profile.ct2_threads
|
| 377 |
+
self._ct2_inter_threads = _env_int("HACHIMIMT_INTER_THREADS", 1, max_value=8)
|
| 378 |
+
self._ct2_window_multiplier = _env_int("HACHIMIMT_CT2_WINDOW_MULTIPLIER", 4, max_value=16)
|
| 379 |
+
self._tokenize_job_size = _env_int("HACHIMIMT_TOKENIZE_JOB_SIZE", 32, max_value=256)
|
| 380 |
+
batch_type = os.environ.get("HACHIMIMT_CT2_BATCH_TYPE", "tokens").strip().lower()
|
| 381 |
+
self._ct2_batch_type = batch_type if batch_type in {"examples", "tokens"} else "tokens"
|
| 382 |
+
self._ct2_compute_type: str | None = None
|
| 383 |
+
self._batch_size = self._profile.batch_size
|
| 384 |
+
self._tokenize_workers = self._profile.tokenize_workers
|
| 385 |
+
self._tokenize_pool: ThreadPoolExecutor | None = None
|
| 386 |
+
|
| 387 |
+
@property
|
| 388 |
+
def hardware_profile(self) -> HardwareProfile:
|
| 389 |
+
return self._profile
|
| 390 |
+
|
| 391 |
+
@property
|
| 392 |
+
def batch_size(self) -> int:
|
| 393 |
+
return self._batch_size
|
| 394 |
+
|
| 395 |
+
def set_batch_size(self, batch_size: int) -> None:
|
| 396 |
+
self._batch_size = max(4, min(128, int(batch_size)))
|
| 397 |
+
|
| 398 |
+
def apply_hardware_profile(self, profile: HardwareProfile | None = None) -> None:
|
| 399 |
+
profile = profile or detect_hardware_profile()
|
| 400 |
+
threads_changed = profile.ct2_threads != self._ct2_threads
|
| 401 |
+
workers_changed = profile.tokenize_workers != self._tokenize_workers
|
| 402 |
+
self._profile = profile
|
| 403 |
+
self._ct2_threads = profile.ct2_threads
|
| 404 |
+
self._batch_size = profile.batch_size
|
| 405 |
+
self._tokenize_workers = profile.tokenize_workers
|
| 406 |
+
if workers_changed and self._tokenize_pool is not None:
|
| 407 |
+
self._tokenize_pool.shutdown(wait=False, cancel_futures=True)
|
| 408 |
+
self._tokenize_pool = None
|
| 409 |
+
|
| 410 |
+
if threads_changed and self._backend == Backend.CT2 and self._model_key:
|
| 411 |
+
model_key = self._model_key
|
| 412 |
+
self._unload_models()
|
| 413 |
+
self._load_ct2(MODELS[model_key])
|
| 414 |
+
self._model_key = model_key
|
| 415 |
+
self._backend = Backend.CT2
|
| 416 |
+
|
| 417 |
+
@property
|
| 418 |
+
def device(self) -> str:
|
| 419 |
+
if self._backend == Backend.CT2 and self._ct2_model is not None:
|
| 420 |
+
return self._ct2_model.device
|
| 421 |
+
return self._torch_device
|
| 422 |
+
|
| 423 |
+
def device_label(self) -> str:
|
| 424 |
+
"""Tên thiết bị inference thực tế (để phân biệt iGPU vs NVIDIA)."""
|
| 425 |
+
if self.device == "cuda":
|
| 426 |
+
return _torch_cuda_device_name() or self._profile.gpu_name or "CUDA GPU"
|
| 427 |
+
return "CPU"
|
| 428 |
+
|
| 429 |
+
@property
|
| 430 |
+
def backend(self) -> Backend | None:
|
| 431 |
+
return self._backend
|
| 432 |
+
|
| 433 |
+
def load(self, model_key: str, backend: Backend | str = Backend.CT2) -> str:
|
| 434 |
+
if isinstance(backend, str):
|
| 435 |
+
backend = Backend(backend)
|
| 436 |
+
|
| 437 |
+
if model_key not in MODELS:
|
| 438 |
+
raise ValueError(f"Unknown model: {model_key}")
|
| 439 |
+
|
| 440 |
+
if (
|
| 441 |
+
self._model_key == model_key
|
| 442 |
+
and self._backend == backend
|
| 443 |
+
and self._tokenizer is not None
|
| 444 |
+
and (self._ct2_model is not None or self._torch_model is not None)
|
| 445 |
+
):
|
| 446 |
+
return self._status_message(model_key, backend, cached=True)
|
| 447 |
+
|
| 448 |
+
config = MODELS[model_key]
|
| 449 |
+
self._unload_models()
|
| 450 |
+
|
| 451 |
+
if backend == Backend.CT2:
|
| 452 |
+
self._load_ct2(config)
|
| 453 |
+
else:
|
| 454 |
+
self._load_transformers(config)
|
| 455 |
+
|
| 456 |
+
self._model_key = model_key
|
| 457 |
+
self._backend = backend
|
| 458 |
+
return self._status_message(model_key, backend, cached=False)
|
| 459 |
+
|
| 460 |
+
def _status_message(
|
| 461 |
+
self,
|
| 462 |
+
model_key: str,
|
| 463 |
+
backend: Backend,
|
| 464 |
+
*,
|
| 465 |
+
cached: bool,
|
| 466 |
+
beam_size: int | None = None,
|
| 467 |
+
) -> str:
|
| 468 |
+
prefix = "Model" if cached else "Đã tải"
|
| 469 |
+
config = MODELS[model_key]
|
| 470 |
+
engine = "CTranslate2 INT8" if backend == Backend.CT2 else "PyTorch"
|
| 471 |
+
msg = f"{prefix} {config.label} · {engine} · {self.device_label()}"
|
| 472 |
+
if backend == Backend.CT2 and self._ct2_compute_type:
|
| 473 |
+
msg += f" · compute={self._ct2_compute_type}"
|
| 474 |
+
msg += (
|
| 475 |
+
f" · batch_type={self._ct2_batch_type}"
|
| 476 |
+
f" · window={self._ct2_window_multiplier}x"
|
| 477 |
+
f" · inter={self._ct2_inter_threads}"
|
| 478 |
+
)
|
| 479 |
+
if beam_size is not None:
|
| 480 |
+
msg += f" · beam={beam_size}"
|
| 481 |
+
return msg
|
| 482 |
+
|
| 483 |
+
@staticmethod
|
| 484 |
+
def clamp_beam(beam_size: int) -> int:
|
| 485 |
+
return max(1, min(4, int(beam_size)))
|
| 486 |
+
|
| 487 |
+
def _unload_models(self) -> None:
|
| 488 |
+
self._torch_model = None
|
| 489 |
+
self._ct2_model = None
|
| 490 |
+
self._tokenizer = None
|
| 491 |
+
self._model_path = None
|
| 492 |
+
self._ct2_compute_type = None
|
| 493 |
+
if self._tokenize_pool is not None:
|
| 494 |
+
self._tokenize_pool.shutdown(wait=False, cancel_futures=True)
|
| 495 |
+
self._tokenize_pool = None
|
| 496 |
+
if self._torch_device == "cuda":
|
| 497 |
+
_torch_empty_cuda_cache()
|
| 498 |
+
|
| 499 |
+
def _get_tokenize_pool(self) -> ThreadPoolExecutor:
|
| 500 |
+
if self._tokenize_pool is None:
|
| 501 |
+
self._tokenize_pool = ThreadPoolExecutor(
|
| 502 |
+
max_workers=self._tokenize_workers,
|
| 503 |
+
thread_name_prefix="hachimi-tokenize",
|
| 504 |
+
)
|
| 505 |
+
return self._tokenize_pool
|
| 506 |
+
|
| 507 |
+
def _tokenize_chunks_parallel(self, chunks: list[str]) -> list[list[str]]:
|
| 508 |
+
if not chunks:
|
| 509 |
+
return []
|
| 510 |
+
if len(chunks) <= self._tokenize_job_size or self._tokenize_workers <= 1:
|
| 511 |
+
return self._source_tokens_batch(chunks)
|
| 512 |
+
pool = self._get_tokenize_pool()
|
| 513 |
+
groups = list(_batched(chunks, self._tokenize_job_size))
|
| 514 |
+
nested = pool.map(self._source_tokens_batch, groups)
|
| 515 |
+
return [tokens for group in nested for tokens in group]
|
| 516 |
+
|
| 517 |
+
def _submit_tokenize_jobs(self, chunks: list[str]) -> SourceTokenJobs:
|
| 518 |
+
pool = self._get_tokenize_pool()
|
| 519 |
+
return [
|
| 520 |
+
pool.submit(self._source_tokens_batch, group)
|
| 521 |
+
for group in _batched(chunks, self._tokenize_job_size)
|
| 522 |
+
]
|
| 523 |
+
|
| 524 |
+
@staticmethod
|
| 525 |
+
def _collect_tokenize_jobs(jobs: SourceTokenJobs) -> list[list[str]]:
|
| 526 |
+
return [tokens for job in jobs for tokens in job.result()]
|
| 527 |
+
|
| 528 |
+
def _decode_ct2_results(self, results) -> list[str]:
|
| 529 |
+
hypotheses = [result.hypotheses[0] for result in results]
|
| 530 |
+
token_ids = [self._tokenizer.convert_tokens_to_ids(tokens) for tokens in hypotheses]
|
| 531 |
+
return [
|
| 532 |
+
text.strip()
|
| 533 |
+
for text in self._tokenizer.batch_decode(token_ids, skip_special_tokens=True)
|
| 534 |
+
]
|
| 535 |
+
|
| 536 |
+
def _load_ct2(self, config: ModelConfig) -> None:
|
| 537 |
+
model_path = ensure_model_files(config, Backend.CT2)
|
| 538 |
+
tokenizer = CT2SentencePieceTokenizer(model_path)
|
| 539 |
+
|
| 540 |
+
env_compute_type = os.environ.get("HACHIMIMT_COMPUTE_TYPE", "").strip()
|
| 541 |
+
ct2_device = "cuda" if self._profile.has_cuda else "cpu"
|
| 542 |
+
attempts = [(ct2_device, default_ct2_compute_type(ct2_device))]
|
| 543 |
+
if not env_compute_type and ct2_device == "cuda":
|
| 544 |
+
attempts.extend([("cuda", "int8_float32"), ("cpu", "int8_float32")])
|
| 545 |
+
elif env_compute_type and ct2_device == "cuda":
|
| 546 |
+
attempts.append(("cpu", "int8_float32"))
|
| 547 |
+
|
| 548 |
+
translator = None
|
| 549 |
+
last_error: Exception | None = None
|
| 550 |
+
for device, compute_type in attempts:
|
| 551 |
+
try:
|
| 552 |
+
translator = ctranslate2.Translator(
|
| 553 |
+
str(model_path / config.ct2_subdir),
|
| 554 |
+
device=device,
|
| 555 |
+
compute_type=compute_type,
|
| 556 |
+
intra_threads=self._ct2_threads,
|
| 557 |
+
inter_threads=self._ct2_inter_threads,
|
| 558 |
+
)
|
| 559 |
+
self._ct2_compute_type = compute_type
|
| 560 |
+
break
|
| 561 |
+
except Exception as exc:
|
| 562 |
+
last_error = exc
|
| 563 |
+
|
| 564 |
+
if translator is None:
|
| 565 |
+
raise RuntimeError("Không tải được CTranslate2 backend.") from last_error
|
| 566 |
+
|
| 567 |
+
self._tokenizer = tokenizer
|
| 568 |
+
self._ct2_model = translator
|
| 569 |
+
self._model_path = model_path
|
| 570 |
+
|
| 571 |
+
def _load_transformers(self, config: ModelConfig) -> None:
|
| 572 |
+
_require_torch()
|
| 573 |
+
try:
|
| 574 |
+
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, MarianMTModel
|
| 575 |
+
except Exception as exc:
|
| 576 |
+
raise RuntimeError(
|
| 577 |
+
"Backend PyTorch cần transformers/sacremoses/safetensors. "
|
| 578 |
+
"Cài thêm: pip install -r requirements-pytorch.txt"
|
| 579 |
+
) from exc
|
| 580 |
+
|
| 581 |
+
model_path = ensure_model_files(config, Backend.TRANSFORMERS)
|
| 582 |
+
tokenizer = AutoTokenizer.from_pretrained(model_path)
|
| 583 |
+
if config.use_marian_class:
|
| 584 |
+
model = MarianMTModel.from_pretrained(model_path)
|
| 585 |
+
else:
|
| 586 |
+
model = AutoModelForSeq2SeqLM.from_pretrained(model_path)
|
| 587 |
+
|
| 588 |
+
model = model.to(self._torch_device).eval()
|
| 589 |
+
self._tokenizer = tokenizer
|
| 590 |
+
self._torch_model = model
|
| 591 |
+
|
| 592 |
+
def _chunk_text(self, text: str, chunk_mode: str) -> list[str]:
|
| 593 |
+
config = MODELS[self._model_key]
|
| 594 |
+
if self._backend == Backend.CT2 and self._tokenizer is not None:
|
| 595 |
+
return split_for_translation(
|
| 596 |
+
self._tokenizer,
|
| 597 |
+
text,
|
| 598 |
+
max_tokens=config.ct2_max_input_tokens,
|
| 599 |
+
chunk_mode=chunk_mode,
|
| 600 |
+
)
|
| 601 |
+
return split_chunks(text, mode=chunk_mode)
|
| 602 |
+
|
| 603 |
+
def _source_tokens(self, text: str) -> list[str]:
|
| 604 |
+
config = MODELS[self._model_key]
|
| 605 |
+
token_ids = source_token_ids(
|
| 606 |
+
self._tokenizer,
|
| 607 |
+
text,
|
| 608 |
+
max_length=config.ct2_max_input_tokens,
|
| 609 |
+
truncation=True,
|
| 610 |
+
)
|
| 611 |
+
return self._tokenizer.convert_ids_to_tokens(token_ids)
|
| 612 |
+
|
| 613 |
+
def _source_tokens_batch(self, chunks: list[str]) -> list[list[str]]:
|
| 614 |
+
config = MODELS[self._model_key]
|
| 615 |
+
encoded = self._tokenizer(
|
| 616 |
+
chunks,
|
| 617 |
+
truncation=True,
|
| 618 |
+
max_length=config.ct2_max_input_tokens,
|
| 619 |
+
padding=False,
|
| 620 |
+
)["input_ids"]
|
| 621 |
+
pad_id = self._tokenizer.pad_token_id
|
| 622 |
+
if pad_id is not None:
|
| 623 |
+
encoded = [
|
| 624 |
+
[token_id for token_id in token_ids if token_id != pad_id]
|
| 625 |
+
for token_ids in encoded
|
| 626 |
+
]
|
| 627 |
+
return [self._tokenizer.convert_ids_to_tokens(token_ids) for token_ids in encoded]
|
| 628 |
+
|
| 629 |
+
def _decode_tokens(self, tokens: list[str]) -> str:
|
| 630 |
+
token_ids = self._tokenizer.convert_tokens_to_ids(tokens)
|
| 631 |
+
return self._tokenizer.decode(token_ids, skip_special_tokens=True).strip()
|
| 632 |
+
|
| 633 |
+
def _torch_generate_kwargs(self, beam_size: int) -> dict:
|
| 634 |
+
config = MODELS[self._model_key]
|
| 635 |
+
kwargs = dict(config.generate_kwargs)
|
| 636 |
+
kwargs["num_beams"] = beam_size
|
| 637 |
+
if config.use_marian_class:
|
| 638 |
+
kwargs["early_stopping"] = beam_size > 1
|
| 639 |
+
return kwargs
|
| 640 |
+
|
| 641 |
+
def _runtime_batch_size(self, beam_size: int) -> int:
|
| 642 |
+
"""PyTorch tốn VRAM hơn theo beam — giảm batch để tránh OOM."""
|
| 643 |
+
if self._backend == Backend.CT2:
|
| 644 |
+
return self._batch_size
|
| 645 |
+
beam_size = self.clamp_beam(beam_size)
|
| 646 |
+
vram_factor = max(1, beam_size * 2)
|
| 647 |
+
return max(4, min(self._batch_size, 48 // vram_factor))
|
| 648 |
+
|
| 649 |
+
def _runtime_window_size(self, beam_size: int) -> int:
|
| 650 |
+
batch_size = self._runtime_batch_size(beam_size)
|
| 651 |
+
if self._backend == Backend.CT2:
|
| 652 |
+
return max(batch_size, batch_size * self._ct2_window_multiplier)
|
| 653 |
+
return batch_size
|
| 654 |
+
|
| 655 |
+
def _ct2_max_batch_size(self, config: ModelConfig) -> int:
|
| 656 |
+
if self._ct2_batch_type == "tokens":
|
| 657 |
+
return self._batch_size * config.ct2_max_input_tokens
|
| 658 |
+
return self._batch_size
|
| 659 |
+
|
| 660 |
+
def _translate_torch_batch(self, chunks: list[str], *, beam_size: int) -> list[str]:
|
| 661 |
+
if not chunks:
|
| 662 |
+
return []
|
| 663 |
+
|
| 664 |
+
torch = _require_torch()
|
| 665 |
+
config = MODELS[self._model_key]
|
| 666 |
+
max_length = 256 if config.use_marian_class else 512
|
| 667 |
+
inputs = self._tokenizer(
|
| 668 |
+
chunks,
|
| 669 |
+
return_tensors="pt",
|
| 670 |
+
padding=True,
|
| 671 |
+
truncation=True,
|
| 672 |
+
max_length=max_length,
|
| 673 |
+
).to(self._torch_device)
|
| 674 |
+
|
| 675 |
+
with torch.inference_mode():
|
| 676 |
+
outputs = self._torch_model.generate(
|
| 677 |
+
**inputs,
|
| 678 |
+
**self._torch_generate_kwargs(beam_size),
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
return [
|
| 682 |
+
self._tokenizer.decode(output, skip_special_tokens=True).strip()
|
| 683 |
+
for output in outputs
|
| 684 |
+
]
|
| 685 |
+
|
| 686 |
+
def translate_chunk(self, text: str, *, beam_size: int = 2) -> str:
|
| 687 |
+
if self._tokenizer is None:
|
| 688 |
+
raise RuntimeError("Chưa tải model. Gọi load() trước.")
|
| 689 |
+
|
| 690 |
+
beam_size = self.clamp_beam(beam_size)
|
| 691 |
+
|
| 692 |
+
if self._backend == Backend.CT2:
|
| 693 |
+
return self._translate_chunks_ct2([text], beam_size=beam_size)[0]
|
| 694 |
+
|
| 695 |
+
return self._translate_torch_batch([text], beam_size=beam_size)[0]
|
| 696 |
+
|
| 697 |
+
def count_chunks(self, text: str, chunk_mode: str = "sentence") -> int:
|
| 698 |
+
if not self._model_key:
|
| 699 |
+
raise RuntimeError("Chưa tải model. Gọi load() trước.")
|
| 700 |
+
return len(self._chunk_text(text, chunk_mode))
|
| 701 |
+
|
| 702 |
+
def _translate_ct2_batch(
|
| 703 |
+
self,
|
| 704 |
+
chunks: list[str],
|
| 705 |
+
*,
|
| 706 |
+
beam_size: int,
|
| 707 |
+
source_batches: list[list[str]] | None = None,
|
| 708 |
+
) -> list[str]:
|
| 709 |
+
config = MODELS[self._model_key]
|
| 710 |
+
if source_batches is None:
|
| 711 |
+
source_batches = self._tokenize_chunks_parallel(chunks)
|
| 712 |
+
results = self._ct2_model.translate_batch(
|
| 713 |
+
source_batches,
|
| 714 |
+
max_batch_size=self._ct2_max_batch_size(config),
|
| 715 |
+
batch_type=self._ct2_batch_type,
|
| 716 |
+
beam_size=beam_size,
|
| 717 |
+
max_decoding_length=config.ct2_max_output_tokens,
|
| 718 |
+
)
|
| 719 |
+
return self._decode_ct2_results(results)
|
| 720 |
+
|
| 721 |
+
def _translate_ct2_batch_pipelined(
|
| 722 |
+
self,
|
| 723 |
+
chunks: list[str],
|
| 724 |
+
*,
|
| 725 |
+
beam_size: int,
|
| 726 |
+
prefetched_tokens: SourceTokenJobs | None,
|
| 727 |
+
) -> list[str]:
|
| 728 |
+
if prefetched_tokens is not None:
|
| 729 |
+
source_batches = self._collect_tokenize_jobs(prefetched_tokens)
|
| 730 |
+
else:
|
| 731 |
+
source_batches = None
|
| 732 |
+
return self._translate_ct2_batch(
|
| 733 |
+
chunks,
|
| 734 |
+
beam_size=beam_size,
|
| 735 |
+
source_batches=source_batches,
|
| 736 |
+
)
|
| 737 |
+
|
| 738 |
+
def _translate_chunks_ct2(self, chunks: list[str], *, beam_size: int) -> list[str]:
|
| 739 |
+
if self._ct2_model is None:
|
| 740 |
+
raise RuntimeError("CTranslate2 chưa được tải.")
|
| 741 |
+
|
| 742 |
+
beam_size = self.clamp_beam(beam_size)
|
| 743 |
+
if not chunks:
|
| 744 |
+
return []
|
| 745 |
+
return self._translate_ct2_batch(chunks, beam_size=beam_size)
|
| 746 |
+
|
| 747 |
+
def translate_text_iter(
|
| 748 |
+
self,
|
| 749 |
+
text: str,
|
| 750 |
+
*,
|
| 751 |
+
chunk_mode: str = "sentence",
|
| 752 |
+
beam_size: int = 2,
|
| 753 |
+
) -> Iterator[tuple[int, int, str, list[tuple[int, str, str]] | None, str | None]]:
|
| 754 |
+
"""Yield (done, total, message, rows_or_none, full_text_or_none) sau mỗi batch."""
|
| 755 |
+
beam_size = self.clamp_beam(beam_size)
|
| 756 |
+
chunks = self._chunk_text(text, chunk_mode)
|
| 757 |
+
total = len(chunks)
|
| 758 |
+
|
| 759 |
+
yield 0, total, f"Đã chia {total} chunk, chuẩn bị dịch...", None, None
|
| 760 |
+
|
| 761 |
+
translations: list[str] = []
|
| 762 |
+
batch_size = self._runtime_batch_size(beam_size)
|
| 763 |
+
window_size = self._runtime_window_size(beam_size)
|
| 764 |
+
next_tokens: SourceTokenJobs | None = None
|
| 765 |
+
if self._backend == Backend.CT2 and total:
|
| 766 |
+
first_end = min(window_size, total)
|
| 767 |
+
next_tokens = self._submit_tokenize_jobs(chunks[:first_end])
|
| 768 |
+
|
| 769 |
+
for start in range(0, total, window_size):
|
| 770 |
+
end = min(start + window_size, total)
|
| 771 |
+
batch_label = (
|
| 772 |
+
f"window {window_size}, batch {batch_size}, {self._ct2_batch_type}"
|
| 773 |
+
if self._backend == Backend.CT2
|
| 774 |
+
else f"batch {batch_size}"
|
| 775 |
+
)
|
| 776 |
+
yield (
|
| 777 |
+
start,
|
| 778 |
+
total,
|
| 779 |
+
f"Đang dịch chunk {start + 1}–{end}/{total} ({batch_label})...",
|
| 780 |
+
None,
|
| 781 |
+
None,
|
| 782 |
+
)
|
| 783 |
+
|
| 784 |
+
batch = chunks[start:end]
|
| 785 |
+
if self._backend == Backend.CT2:
|
| 786 |
+
current_tokens = next_tokens
|
| 787 |
+
next_tokens = None
|
| 788 |
+
next_start = start + window_size
|
| 789 |
+
next_end = min(next_start + window_size, total)
|
| 790 |
+
if next_start < total:
|
| 791 |
+
next_batch = chunks[next_start:next_end]
|
| 792 |
+
next_tokens = self._submit_tokenize_jobs(next_batch)
|
| 793 |
+
|
| 794 |
+
translations.extend(
|
| 795 |
+
self._translate_ct2_batch_pipelined(
|
| 796 |
+
batch,
|
| 797 |
+
beam_size=beam_size,
|
| 798 |
+
prefetched_tokens=current_tokens,
|
| 799 |
+
)
|
| 800 |
+
)
|
| 801 |
+
else:
|
| 802 |
+
translations.extend(self._translate_torch_batch(batch, beam_size=beam_size))
|
| 803 |
+
|
| 804 |
+
yield end, total, f"Đã xong {end}/{total} chunk", None, None
|
| 805 |
+
|
| 806 |
+
rows = [
|
| 807 |
+
(index, chunk, translated)
|
| 808 |
+
for index, (chunk, translated) in enumerate(zip(chunks, translations), start=1)
|
| 809 |
+
]
|
| 810 |
+
full_text = "\n".join(translations)
|
| 811 |
+
yield total, total, "Hoàn tất dịch.", rows, full_text
|
| 812 |
+
|
| 813 |
+
def translate_text(
|
| 814 |
+
self,
|
| 815 |
+
text: str,
|
| 816 |
+
*,
|
| 817 |
+
chunk_mode: str = "sentence",
|
| 818 |
+
beam_size: int = 2,
|
| 819 |
+
on_progress: Callable[[int, int, str], None] | None = None,
|
| 820 |
+
) -> tuple[list[tuple[int, str, str]], str]:
|
| 821 |
+
rows: list[tuple[int, str, str]] = []
|
| 822 |
+
full_text = ""
|
| 823 |
+
for done, total, message, result_rows, result_text in self.translate_text_iter(
|
| 824 |
+
text,
|
| 825 |
+
chunk_mode=chunk_mode,
|
| 826 |
+
beam_size=beam_size,
|
| 827 |
+
):
|
| 828 |
+
if on_progress:
|
| 829 |
+
on_progress(done, total, message)
|
| 830 |
+
if result_rows is not None and result_text is not None:
|
| 831 |
+
rows = result_rows
|
| 832 |
+
full_text = result_text
|
| 833 |
+
return rows, full_text
|