* fix(engine): recognize complete umlaut aliases in quanpin * refactor(engine): 移除九键输入 九键会话只有引擎公共 Session 门面在用,Server 与 TSF 都没有接入。删掉 NineKeySession、Session 的九键入口与 SessionSnapshot::nine_key_spellings, 以及对应测试和注释。 * fix(engine,server): ü 别名共用一条归一规则,入库与纠错不再产出查不到的键 - 新增 quanpin::is_jqxy_v_alias / canonical_umlaut_spelling,替换 canonical_syllable 与 to_google_spelling 里各自手抄的别名表,normalize_umlaut_aliases 也改用它。 - 纠错表生成器与运行时生成式空间不再从 jqxy+v 别名派生纠错键(nvan -> jvan 之类), 手误只纠到 u 的标准写法;重新生成 autocorrect_table.h。 - 个人词库新写入的拼音词、设置页导入的全拼按词库写法入库(xvan -> xuan、lue -> lve), 否则查询归一后永远查不到;被替换的旧词保留原键,旧行仍可删除。 - 双拼音节集统一排除 jqxy+v 别名,round-trip 检查不再特判。 --------- Co-authored-by: fanlusky <1730976608@qq.com>
346 lines
14 KiB
Python
346 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""Generate engine/quanpin/autocorrect_table.h from quanpin_utils.cpp.
|
||
|
||
The intact pinyin list is parsed directly from the engine source, so the
|
||
generated table can never drift from the runtime syllable list.
|
||
|
||
The table is split in four so each autocorrection type can be toggled
|
||
independently (quanpin.autocorrect_transposition / autocorrect_neighbor,
|
||
both default off):
|
||
- transposition: single adjacent-letter swap, sahng -> shang
|
||
- neighbor: single QWERTY neighbor-key substitution, shabg -> shang
|
||
- deletion: single dropped letter, shng -> shang
|
||
- insertion: single inserted QWERTY-neighbor letter, sjhang -> shang
|
||
|
||
A wrong key may map to several syllables (e.g. ahan -> {shan, zhan}). Such
|
||
ambiguous variants are kept instead of dropped: the runtime disambiguates
|
||
with k-best cuts and word frequency, the probability-weighted approach of
|
||
google patent CN 101133411 B. A (wrong, correct) pair produced by two rules
|
||
stays in both tables so each per-type toggle remains effective on its own.
|
||
|
||
Variant length is capped at >= 3: every 2-letter string belongs to the
|
||
jianpin (abbreviated pinyin) space, where "wj" means w+j and must never be
|
||
rewritten into "wu" by the correction table.
|
||
|
||
Usage:
|
||
python scripts/generate_quanpin_autocorrect.py # regenerate the header
|
||
python scripts/generate_quanpin_autocorrect.py --check # verify the header is up to date (CI)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
|
||
ENGINE_DIR = pathlib.Path(__file__).resolve().parents[2] / "engine"
|
||
UTILS_PATH = ENGINE_DIR / "quanpin" / "quanpin_utils.cpp"
|
||
OUT_PATH = ENGINE_DIR / "quanpin" / "autocorrect_table.h"
|
||
|
||
# QWERTY letter-key neighbors (finger-movement range, letter keys only).
|
||
QWERTY_NEIGHBORS = {
|
||
"q": ["w", "a"],
|
||
"w": ["q", "e", "s", "a"],
|
||
"e": ["w", "r", "s", "d"],
|
||
"r": ["e", "t", "d", "f"],
|
||
"t": ["r", "y", "f", "g"],
|
||
"y": ["t", "u", "g", "h"],
|
||
"u": ["y", "i", "h", "j"],
|
||
"i": ["u", "o", "j", "k"],
|
||
"o": ["i", "p", "k", "l"],
|
||
"p": ["o", "l"],
|
||
"a": ["q", "w", "s", "z"],
|
||
"s": ["a", "w", "d", "e", "x", "z"],
|
||
"d": ["s", "e", "r", "f", "c", "x"],
|
||
"f": ["d", "r", "t", "g", "v", "c"],
|
||
"g": ["f", "t", "y", "h", "b", "v"],
|
||
"h": ["g", "y", "u", "j", "n", "b"],
|
||
"j": ["h", "u", "i", "k", "m", "n"],
|
||
"k": ["j", "i", "o", "l", "m"],
|
||
"l": ["k", "o", "p"],
|
||
"z": ["a", "s", "x"],
|
||
"x": ["z", "s", "d", "c"],
|
||
"c": ["x", "d", "f", "v"],
|
||
"v": ["c", "f", "g", "b"],
|
||
"b": ["v", "g", "h", "n"],
|
||
"m": ["n", "j", "k"],
|
||
"n": ["b", "h", "j", "m"],
|
||
}
|
||
|
||
HEADER_TEMPLATE = """\
|
||
// Generated by scripts/generate_quanpin_autocorrect.py -- DO NOT EDIT.
|
||
// Quanpin typing autocorrection tables: wrong spelling -> correct syllable.
|
||
// Rules: single adjacent-letter transposition, one QWERTY neighbor-key
|
||
// substitution, one dropped letter, or one inserted letter (a repeat of an
|
||
// adjacent key or one of its QWERTY neighbors), applied to a legal syllable.
|
||
// Every key is a non-legal pinyin string of at least three letters (2-letter
|
||
// strings belong to the jianpin space). One key may map to several syllables:
|
||
// ambiguous variants are kept for runtime disambiguation via k-best cuts and
|
||
// word frequency (probability-weighted correction, cf. CN 101133411 B).
|
||
// Each table backs an independently toggleable correction type, and a pair
|
||
// produced by two rules stays in both tables.
|
||
//
|
||
// Correct syllables are stored once in kCorrectSyllables and referenced by
|
||
// 16-bit index from every Entry instead of repeating the same ~420 strings
|
||
// across 17k+ rows. The snapshot is re-extracted from intact_pinyin_list()
|
||
// in quanpin_utils.cpp on every generator run, so the indices can never
|
||
// drift from the runtime syllable list; --check fails on a stale header.
|
||
#pragma once
|
||
|
||
#include <cstddef>
|
||
#include <cstdint>
|
||
#include <string_view>
|
||
|
||
namespace quanpin::autocorrect
|
||
{{
|
||
|
||
// Every legal syllable, in intact_pinyin_list() order.
|
||
inline constexpr std::string_view kCorrectSyllables[] = {{
|
||
{syllable_entries}
|
||
}};
|
||
|
||
struct Entry
|
||
{{
|
||
const char *wrong;
|
||
std::uint16_t correct; // index into kCorrectSyllables
|
||
}};
|
||
|
||
// Adjacent-letter transpositions: sahng -> shang.
|
||
inline constexpr Entry kTranspositionEntries[] = {{
|
||
{transposition_entries}
|
||
}};
|
||
|
||
// QWERTY neighbor-key substitutions: shabg -> shang (n -> b).
|
||
inline constexpr Entry kNeighborEntries[] = {{
|
||
{neighbor_entries}
|
||
}};
|
||
|
||
// Dropped letters: shng -> shang (missing a).
|
||
inline constexpr Entry kDeletionEntries[] = {{
|
||
{deletion_entries}
|
||
}};
|
||
|
||
// Inserted letters: sjhang -> shang (j is a QWERTY neighbor of h).
|
||
inline constexpr Entry kInsertionEntries[] = {{
|
||
{insertion_entries}
|
||
}};
|
||
|
||
inline constexpr std::size_t kTranspositionCount = sizeof(kTranspositionEntries) / sizeof(kTranspositionEntries[0]);
|
||
inline constexpr std::size_t kNeighborCount = sizeof(kNeighborEntries) / sizeof(kNeighborEntries[0]);
|
||
inline constexpr std::size_t kDeletionCount = sizeof(kDeletionEntries) / sizeof(kDeletionEntries[0]);
|
||
inline constexpr std::size_t kInsertionCount = sizeof(kInsertionEntries) / sizeof(kInsertionEntries[0]);
|
||
|
||
}} // namespace quanpin::autocorrect
|
||
"""
|
||
|
||
|
||
def extract_intact_pinyin_list() -> list[str]:
|
||
text = UTILS_PATH.read_text(encoding="utf-8")
|
||
match = re.search(r"intact_pinyin_list\(\)[\s\S]*?kList = \{([\s\S]*?)\};", text)
|
||
if not match:
|
||
raise SystemExit("ERROR: cannot find intact_pinyin_list in " + str(UTILS_PATH))
|
||
return re.findall(r'"([a-z]+)"', match.group(1))
|
||
|
||
|
||
def is_jqxy_v_alias(syllable: str) -> bool:
|
||
# Mirrors quanpin::is_jqxy_v_alias: jv/jve/xvan/... only alias the u spelling.
|
||
# Nobody types them on purpose, so typos are corrected to the canonical
|
||
# syllable only; deriving keys from the alias would add noise like nvan -> jvan.
|
||
return len(syllable) >= 2 and syllable[0] in "jqxy" and syllable[1] == "v"
|
||
|
||
|
||
def build_variants(
|
||
syllables: list[str], make_variants, min_length: int
|
||
) -> dict[str, set[str]]:
|
||
"""Run one rule path over all syllables, keeping ambiguous variants.
|
||
|
||
A wrong string that maps to several syllables keeps every mapping: the
|
||
runtime disambiguates with k-best cuts and word frequency (probability-
|
||
weighted correction, cf. CN 101133411 B) instead of dropping the key at
|
||
generation time.
|
||
"""
|
||
valid = set(syllables)
|
||
mapping: dict[str, set[str]] = {}
|
||
|
||
def add_variant(variant: str, correct: str) -> None:
|
||
if (
|
||
variant == correct
|
||
or variant in valid
|
||
or len(variant) < min_length
|
||
or len(variant) > 6
|
||
or "'" in variant
|
||
):
|
||
return
|
||
mapping.setdefault(variant, set()).add(correct)
|
||
|
||
for syllable in syllables:
|
||
if len(syllable) < 2 or is_jqxy_v_alias(syllable):
|
||
continue
|
||
for variant in make_variants(syllable):
|
||
add_variant(variant, syllable)
|
||
|
||
return mapping
|
||
|
||
|
||
def insertion_variants(syllable: str):
|
||
# sjhang -> shang: insert one letter at any position. The inserted letter
|
||
# must look like a real fat-finger: one of the QWERTY neighbors of an
|
||
# adjacent key. Unconstrained insertion would generate ~60k mostly-
|
||
# unrealistic keys, so position anchors are start (right neighbor only),
|
||
# end (left neighbor only), and middle (either side). A double tap (repeat
|
||
# of the adjacent key itself) is deliberately NOT generated: it is rare
|
||
# enough that the widened false-positive surface is not worth it. The <=6
|
||
# length cap means 6-letter syllables (zhuang) get no insertion coverage
|
||
# (their variants would be 7 letters, beyond every legal syllable) --
|
||
# deliberate, re-evaluate only if evaluation shows a real gap for 6-letter
|
||
# syllables.
|
||
for position in range(len(syllable) + 1):
|
||
candidates: set[str] = set()
|
||
if position > 0:
|
||
left = syllable[position - 1]
|
||
candidates.update(QWERTY_NEIGHBORS.get(left, ()))
|
||
if position < len(syllable):
|
||
right = syllable[position]
|
||
candidates.update(QWERTY_NEIGHBORS.get(right, ()))
|
||
for letter in candidates:
|
||
yield syllable[:position] + letter + syllable[position:]
|
||
|
||
|
||
def build_entries(
|
||
syllables: list[str],
|
||
) -> tuple[dict[str, set[str]], dict[str, set[str]], dict[str, set[str]], dict[str, set[str]]]:
|
||
def transposition_variants(syllable: str):
|
||
# sahng -> shang: swap each adjacent letter pair.
|
||
return (
|
||
syllable[:i] + syllable[i + 1] + syllable[i] + syllable[i + 2 :]
|
||
for i in range(len(syllable) - 1)
|
||
)
|
||
|
||
def neighbor_variants(syllable: str):
|
||
# shabg -> shang: replace each letter with a QWERTY neighbor.
|
||
return (
|
||
syllable[:i] + neighbor + syllable[i + 1 :]
|
||
for i, ch in enumerate(syllable)
|
||
for neighbor in QWERTY_NEIGHBORS.get(ch, ())
|
||
)
|
||
|
||
def deletion_variants(syllable: str):
|
||
# shng -> shang: drop each single letter.
|
||
return (syllable[:i] + syllable[i + 1 :] for i in range(len(syllable)))
|
||
|
||
# 2-letter strings are jianpin space (wj/wh/wk/wl...), never correction keys.
|
||
transposition = build_variants(syllables, transposition_variants, min_length=3)
|
||
neighbor = build_variants(syllables, neighbor_variants, min_length=3)
|
||
deletion = build_variants(syllables, deletion_variants, min_length=3)
|
||
insertion = build_variants(syllables, insertion_variants, min_length=3)
|
||
|
||
# Cross-type conflicts are intentionally kept in every table that produces
|
||
# them: the runtime indexes each table per correction type with its own
|
||
# edge weight, so a pair shared by two rules is what keeps the per-type
|
||
# toggles independently effective.
|
||
return transposition, neighbor, deletion, insertion
|
||
|
||
|
||
# engine/.clang-format pins ColumnLimit: 120; the generator must emit the
|
||
# clang-format-stable bytes itself because CI gates the header twice
|
||
# (generator --check and scripts/format.sh --check) on the same file.
|
||
COLUMN_LIMIT = 120
|
||
|
||
|
||
def render_grid(cells: list[str]) -> str:
|
||
"""Compact grid mirroring clang-format's column-aligned brace-list layout
|
||
(verified byte-identical against the pinned formatter on all tables):
|
||
|
||
- cell i lands in column i % columns;
|
||
- each column is as wide as its own longest cell plus one separator
|
||
space, except the trailing column which needs no separator;
|
||
- the column count is the widest one whose full rows stay inside the
|
||
column limit, scanning downward.
|
||
|
||
Both the per-column widths and the column count must be computed, not
|
||
fixed: clang-format tries the widest packing first, so the short index
|
||
cells reflow to a different column count than the string cells they
|
||
replace (7/6 columns where the old string cells gave 5).
|
||
"""
|
||
if not cells:
|
||
return ""
|
||
for columns in range(min(len(cells), COLUMN_LIMIT - 4), 1, -1):
|
||
slots = [max(len(cell) for cell in cells[col::columns]) + 1 for col in range(columns - 1)]
|
||
slots.append(max(len(cell) for cell in cells[columns - 1 :: columns]))
|
||
if 4 + sum(slots) > COLUMN_LIMIT:
|
||
continue
|
||
lines = []
|
||
for i in range(0, len(cells), columns):
|
||
chunk = cells[i : i + columns]
|
||
lines.append(" " + "".join(cell.ljust(slots[j]) for j, cell in enumerate(chunk)).rstrip())
|
||
return "\n".join(lines)
|
||
# Single-column fallback: one cell per line, nothing to align.
|
||
return "\n".join(" " + cell for cell in cells)
|
||
|
||
|
||
def render_entry_lines(entries: dict[str, set[str]], syllable_index: dict[str, int]) -> str:
|
||
# Sort by the correct string, not by its index, so the emitted row order
|
||
# -- the runtime's deterministic "table order" tie-break -- stays exactly
|
||
# what the string-keyed tables produced before the index encoding.
|
||
pairs = sorted(
|
||
(wrong, correct) for wrong, corrects in entries.items() for correct in corrects
|
||
)
|
||
cells = []
|
||
for wrong, correct in pairs:
|
||
index = syllable_index.get(correct)
|
||
if index is None:
|
||
raise SystemExit(
|
||
f"ERROR: correct syllable {correct!r} is missing from the extracted intact pinyin list"
|
||
)
|
||
cells.append(f'{{"{wrong}", {index}}},')
|
||
return render_grid(cells)
|
||
|
||
|
||
def render_header(
|
||
syllables: list[str],
|
||
transposition: dict[str, set[str]],
|
||
neighbor: dict[str, set[str]],
|
||
deletion: dict[str, set[str]],
|
||
insertion: dict[str, set[str]],
|
||
) -> str:
|
||
syllable_index = {syllable: i for i, syllable in enumerate(syllables)}
|
||
return HEADER_TEMPLATE.format(
|
||
syllable_entries=render_grid([f'"{syllable}",' for syllable in syllables]),
|
||
transposition_entries=render_entry_lines(transposition, syllable_index),
|
||
neighbor_entries=render_entry_lines(neighbor, syllable_index),
|
||
deletion_entries=render_entry_lines(deletion, syllable_index),
|
||
insertion_entries=render_entry_lines(insertion, syllable_index),
|
||
)
|
||
|
||
|
||
def main() -> int:
|
||
syllables = extract_intact_pinyin_list()
|
||
if not syllables:
|
||
raise SystemExit("ERROR: extracted empty pinyin list")
|
||
transposition, neighbor, deletion, insertion = build_entries(syllables)
|
||
rendered = render_header(syllables, transposition, neighbor, deletion, insertion)
|
||
|
||
if "--check" in sys.argv[1:]:
|
||
if not OUT_PATH.exists():
|
||
raise SystemExit(f"ERROR: {OUT_PATH} is missing")
|
||
if OUT_PATH.read_text(encoding="utf-8") != rendered:
|
||
raise SystemExit(f"ERROR: {OUT_PATH} is stale; rerun generate_quanpin_autocorrect.py")
|
||
print(
|
||
f"OK: {OUT_PATH} is up to date ({len(syllables)} syllables + {len(transposition)} transposition + "
|
||
f"{len(neighbor)} neighbor + {len(deletion)} deletion + {len(insertion)} insertion keys)"
|
||
)
|
||
return 0
|
||
|
||
# newline="\n" 钉住字节级输出:CI 的 --check 在 Linux 上写 LF,Windows 的
|
||
# 默认换行转换会把文件改成 CRLF,两端产物必须一致。
|
||
OUT_PATH.write_text(rendered, encoding="utf-8", newline="\n")
|
||
print(
|
||
f"Wrote {OUT_PATH} with {len(syllables)} syllables + {len(transposition)} transposition + "
|
||
f"{len(neighbor)} neighbor + {len(deletion)} deletion + {len(insertion)} insertion keys"
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|