File size: 7,685 Bytes
6cc3500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
"""Safe normalization:只做**無歧義**的確定性轉換(TWLAT-R 的 deterministic layer)。

與 V1 的差別,以及為什麼要換掉 zhtw-mcp 的 fixer:

  V1 直接用 `zhtw-mcp convert` 當 deterministic layer。實測它會製造三類錯誤,
  而且模型無權修正(因為錯誤發生在模型看到文字之前):
    1. 引號配對會刪除字元(『稲亭物怪録』→ 稲亭物怪録)
    2. casing 規則改動 inline code 內的識別字(`typescript` → `TypeScript`)
    3. 詞表做了語境相依的決策(商调制度 → 商調製度、十姑娘 → 十姑孃)

  本模組只做「任何語境下都對」的轉換,其餘一律交給模型當 proposal:
    - 引號正規化(自行實作,不刪字元)
    - **單候選**簡→繁字元轉換(一簡多繁一律不碰,交給模型)
    - CJK 相鄰的半形→全形標點
    - CJK 與拉丁/數字之間補空格

  zhtw-mcp 仍然是**字典來源**(1,853 條規則的語意條件),這是它真正的價值;
  但它的 fixer 不再位於資料路徑上。
"""
from __future__ import annotations

import json
import pathlib

import regex

from twlat import quotes
from twlat.paths import data_file

HAN = regex.compile(r"\p{Han}")
CJK = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]")
LATIN_NUM = regex.compile(r"[A-Za-z0-9]")


# 一簡多繁的預設值改以**臺灣語料頻率**排序,取代 OpenCC 的任意順序。
# 實測 OpenCC 第一候選在 36/239 個字上是錯的:
#   里→裏(語料 12,333 vs 里 274,290)、吃→喫(326 vs 68,673)、
#   朴→樸(2,401 vs 6,223,害「朴正熙」變「樸正熙」)、咸→鹹(咸豐帝變鹹豐帝)
# 由 tools/rank_variants.py 產生。
_RANK_P = data_file("dict/variant_rank.json")
VARIANT_ORDER: dict[str, list[str]] = (
    json.loads(_RANK_P.read_text(encoding="utf-8"))["order"]
    if _RANK_P.exists() else {})
# D-12 政策例外:台/臺 沿用 zhtw-mcp strict 的規範,不依頻率。
POLICY_OVERRIDE = {"台": "臺"}


def _load_s2t() -> dict[str, str]:
    """簡→繁字元表。

    一簡多繁**也要轉**,取 OpenCC 的第一候選(最常見)——因為留著簡體字
    比選錯繁體更糟(zhtw-mcp 就是選擇留簡體,實測「復置」被輸出成「复置」,
    比純 OpenCC 還差)。真正的選擇留給 proposal:該位置會被提出候選集,
    由模型依語境決定要不要改成別的候選。
    """
    m: dict[str, str] = {}
    p = data_file("dict/rules/st_characters.tsv")
    for line in p.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        parts = line.split("\t")
        if len(parts) != 2:
            continue
        src, cands = parts[0], parts[1].split()
        if len(src) != 1 or not cands or len(cands[0]) != 1:
            continue
        if src == cands[0]:
            continue
        m[src] = cands[0]
    # 以語料頻率覆寫一簡多繁的預設值
    for s, order in VARIANT_ORDER.items():
        if len(s) == 1 and order and s in m:
            m[s] = order[0]
    m.update(POLICY_OVERRIDE)
    return m


S2T_CHAR = _load_s2t()


def _load_twv() -> dict[str, str]:
    """OpenCC TWVariants 39 條臺標變體(裏→裡、着→著、喫→吃)。

    V3 專用(do_twv=True):確保 base 文本與 cloze 預訓練語料都是臺標形。
    V1/V2 預設關閉——它們的模型與已發表數字是在無此正規化下訓練/評測的,
    改變共用預設會靜默移動凍結的 baseline。
    """
    m: dict[str, str] = {}
    p = data_file("dict/rules/tw_variants.tsv")
    for line in p.read_text(encoding="utf-8").splitlines():
        parts = line.split("\t")
        if len(parts) == 2 and len(parts[0]) == 1:
            m[parts[0]] = parts[1].split()[0]
    return m


TWV_CHAR = _load_twv()
# S2T 輸出值一併臺標化(st_characters 第一候選可能是 裏 這類非臺標形)
S2T_TW = {k: "".join(TWV_CHAR.get(c, c) for c in v) for k, v in S2T_CHAR.items()}

# V3.1:**常用**的自候選歧義字不做預轉換——選擇完全交給 lattice+模型。
# V3.0 的教訓(bench 錯誤解剖):
#   1. POLICY_OVERRIDE 台→臺 與真實語料寫法衝突,獨佔 40% 的 keep 錯誤;
#   2. 頻率預設會翻轉正確輸入(核准→核準),製造本不存在的 change 任務。
# 判準:該字是自己的候選之一(st_characters 行含自身,如 里→裏 里)
# **且**臺灣語料頻率 ≥ 500(排除 广/厂 這類 OpenCC 視為罕見繁體、
# 實際上留著就是簡體殘留的字——广 頻率 30 vs 台 399,155)。
_VR_FREQ: dict[str, int] = json.loads(
    data_file("dict/variant_rank.json").read_text(encoding="utf-8"))["freq"]
_SELF_OK: set[str] = set()
for _line in data_file("dict/rules/st_characters.tsv").read_text(
        encoding="utf-8").splitlines():
    _p = _line.split("\t")
    if len(_p) == 2 and _p[0] in _p[1].split() \
            and _VR_FREQ.get(_p[0], 0) >= 500:
        _SELF_OK.add(_p[0])
S2T_TW_V3 = {k: v for k, v in S2T_TW.items() if k not in _SELF_OK}

HALF_FULL = {",": ",", ";": ";", ":": ":", "!": "!", "?": "?",
             "(": "(", ")": ")"}


def _punct(text: str) -> str:
    """CJK 相鄰的半形標點轉全形。句點另外處理(避免動到小數與副檔名)。"""
    out = list(text)
    n = len(text)
    for i, ch in enumerate(text):
        if ch not in HALF_FULL and ch != ".":
            continue
        prev = text[i - 1] if i else ""
        nxt = text[i + 1] if i + 1 < n else ""
        if ch == ".":
            # 只在前一字是 CJK 且下一字非數字時轉句號
            if CJK.match(prev or " ") and not (nxt and nxt.isdigit()):
                out[i] = "。"
            continue
        if CJK.match(prev or " ") or CJK.match(nxt or " "):
            out[i] = HALF_FULL[ch]
            # 轉全形後要吃掉緊跟的空格,否則會留下「, 」這種贅格
            if nxt == " ":
                out[i + 1] = ""
    return "".join(out)


def _spacing(text: str) -> str:
    """CJK 與拉丁/數字之間補一個半形空格;已有空白則不重複。"""
    out = []
    for i, ch in enumerate(text):
        if i:
            a, b = text[i - 1], ch
            need = (CJK.match(a) and LATIN_NUM.match(b)) or \
                   (LATIN_NUM.match(a) and CJK.match(b))
            if need:
                out.append(" ")
        out.append(ch)
    return "".join(out)


def safe_normalize(text: str, do_quotes: bool = True, do_s2t: bool = True,
                   do_punct: bool = True, do_spacing: bool = True,
                   do_twv: bool = False) -> str:
    """只套用無歧義轉換。各步驟可關閉以做消融。do_twv 見 _load_twv 註解。"""
    t = text
    if do_quotes:
        t = quotes.normalize(t)
    if do_s2t:
        s2t = S2T_TW_V3 if do_twv else S2T_CHAR
        t = "".join(s2t.get(c, c) for c in t)
    if do_twv:
        t = "".join(TWV_CHAR.get(c, c) for c in t)
    if do_punct:
        t = _punct(t)
    if do_spacing:
        t = _spacing(t)
    return t


def stats() -> dict:
    return {"unambiguous_s2t_chars": len(S2T_CHAR)}


if __name__ == "__main__":
    print(stats())
    for s in ["这个程序有bug,请在服务器上部署。",
              "现行公务人员指名商调制度",
              "十姑娘的香港法律顾问",
              "他问:“老师,‘有条不紊’的‘紊’是什么意思?”",
              "版本 v1.2.3 已发布,请访问 https://a.b/c 。"]:
        print(f"\nIN : {s}\nOUT: {safe_normalize(s)}")