| """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]") |
|
|
|
|
| |
| |
| |
| |
| |
| _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 {}) |
| |
| 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_TW = {k: "".join(TWV_CHAR.get(c, c) for c in v) for k, v in S2T_CHAR.items()} |
|
|
| |
| |
| |
| |
| |
| |
| |
| _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 == ".": |
| |
| 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)}") |
|
|