"""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)}")