ngocdang83 commited on
Commit
e9015b1
·
verified ·
1 Parent(s): ac6c921

Init Space: HachimiMT zh-vi demo (CT2, chuẩn hóa xưng hô)

Browse files
.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 Demo
3
- emoji: 😻
4
- colorFrom: green
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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